From 923532f5f78c4e822ac69347909d2182b8897b12 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 11 Jul 2026 12:45:14 +0200 Subject: [PATCH] Add block plugins: plugin_block node with sandboxed rendering and editing (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The powerful end of the plugin spectrum (ADR 0008 extension point `block`): - Shared schema: the reserved `plugin_block` node — a block atom carrying pluginId, blockType, and the block data as a JSON object. Its DOM shape round-trips the full state in data attributes (clipboard-safe), markdown maps to a reserved fence (```dorfteich-plugin / + data JSON body, fence-escalated when the payload contains backticks), and the content-cache HTML renders a data-carrying neutral placeholder until the export fallbacks land (#79). - Editor: a React NodeView hosts the #73 sandbox — render lifecycle on mount, an edit affordance switching the frame to the plugin's edit mode, and the blockData capability persisting through node attrs (a normal editor transaction, so Yjs replicates it; writes are refused on read-only editors, and the plugin's own attr echo is suppressed so its edit UI never resets mid-typing). Collaborator changes re-invoke the current lifecycle, keeping frames live. The page surface (ids, openPage) flows through a React context like the wikilink pattern; the toolbar gets an insert picker fed from the active code plugins' block extension points. - Fallback: GET /plugins/:id/fallback resolves the manifest fallback from the stored snapshot — it survives uninstall as a tombstone, image fallbacks degrade to neutral once assets are gone. Signed-in only. - e2e plugin-blocks.spec.ts covers all four acceptance criteria: insert → edit → reload round-trip, live two-user collab, disable → fallback → re-enable without document mutation, and copy/paste within and across pages (the markdown clipboard carries the reserved fence). getBlock (cross-page block embedding) stays deferred as in #74: the schema has no per-block ids yet. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .../src/plugins/plugin-assets.controller.ts | 16 ++ apps/api/src/plugins/plugins.e2e.db.test.ts | 42 +++ apps/api/src/plugins/plugins.service.ts | 31 +- apps/web/e2e/plugin-blocks.spec.ts | 264 ++++++++++++++++++ apps/web/e2e/plugin-fixtures.ts | 54 ++++ apps/web/src/editor/PluginBlockMenu.tsx | 62 ++++ apps/web/src/editor/Toolbar.tsx | 14 +- apps/web/src/editor/document-extensions.ts | 2 + apps/web/src/editor/nodes/plugin-block.tsx | 250 +++++++++++++++++ apps/web/src/editor/plugin-block-context.tsx | 21 ++ apps/web/src/pages/PageEditorPage.tsx | 122 +++++--- apps/web/src/plugins/use-pond-plugins.ts | 18 ++ apps/web/src/styles/base.css | 47 ++++ packages/shared/i18n/de/editor.json | 4 + packages/shared/i18n/de/plugins.json | 5 + packages/shared/i18n/en/editor.json | 4 + packages/shared/i18n/en/plugins.json | 5 + .../shared/src/editor-schema/html.test.ts | 14 + packages/shared/src/editor-schema/html.ts | 14 + .../shared/src/editor-schema/markdown.test.ts | 41 +++ packages/shared/src/editor-schema/markdown.ts | 61 +++- packages/shared/src/editor-schema/schema.ts | 46 ++- packages/shared/src/plugins.ts | 14 + 23 files changed, 1097 insertions(+), 54 deletions(-) create mode 100644 apps/web/e2e/plugin-blocks.spec.ts create mode 100644 apps/web/src/editor/PluginBlockMenu.tsx create mode 100644 apps/web/src/editor/nodes/plugin-block.tsx create mode 100644 apps/web/src/editor/plugin-block-context.tsx diff --git a/apps/api/src/plugins/plugin-assets.controller.ts b/apps/api/src/plugins/plugin-assets.controller.ts index e9b50d7..c36f5a4 100644 --- a/apps/api/src/plugins/plugin-assets.controller.ts +++ b/apps/api/src/plugins/plugin-assets.controller.ts @@ -8,9 +8,11 @@ import { StreamableFile, } from '@nestjs/common'; import type { Request, Response } from 'express'; +import type { PluginFallbackView } from '@dorfteich/shared'; import { Public } from '../auth/auth.guard'; import { AppConfig } from '../config/app-config.service'; +import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { buildPluginAssetBase, buildPluginFrameCsp, buildPluginFrameHtml } from './plugin-frame'; import { PluginStorageService } from './plugin-storage.service'; @@ -52,6 +54,20 @@ export class PluginAssetsController { private readonly config: AppConfig, ) {} + /** + * The manifest fallback a `plugin_block` shows while its plugin is inactive + * (issue #76). Deliberately NOT `@Public`: it names installed plugins, which + * is instance metadata for signed-in users, not sandbox-servable content. + * 404 covers "never installed" — the client shows a neutral placeholder. + */ + @Get(':id/fallback') + @AuthenticatedOnly() + async fallback(@Param('id') id: string): Promise { + const view = await this.plugins.fallbackFor(id); + if (!view) throw new NotFoundException(); + return view; + } + /** * The sandbox frame document (#73). Declared before the asset wildcard so * the static segment wins. The CSP pins every load to this plugin's asset diff --git a/apps/api/src/plugins/plugins.e2e.db.test.ts b/apps/api/src/plugins/plugins.e2e.db.test.ts index 223a027..493070c 100644 --- a/apps/api/src/plugins/plugins.e2e.db.test.ts +++ b/apps/api/src/plugins/plugins.e2e.db.test.ts @@ -296,6 +296,48 @@ describe.skipIf(!hasTestDb)('plugins install (e2e, issue #71)', () => { .expect(403); }); + it('serves the manifest fallback for blocks, surviving uninstall as text (#76)', async () => { + await api() + .post('/api/v1/admin/plugins') + .set('Cookie', adminCookie) + .attach( + 'file', + pluginZip( + codeManifest({ + id: 'fally', + extensionPoints: [{ type: 'block', id: 'diagram', title: { de: 'D', en: 'D' } }], + fallback: { type: 'text', value: '[Diagramm]' }, + }), + ), + 'f.zip', + ) + .expect(201); + + // Any signed-in user may resolve a fallback; anonymous requests may not. + const view = await api() + .get('/api/v1/plugins/fally/fallback') + .set('Cookie', outsiderCookie) + .expect(200); + expect(view.body).toMatchObject({ + pluginId: 'fally', + fallback: { type: 'text', value: '[Diagramm]' }, + }); + await api().get('/api/v1/plugins/fally/fallback').expect(401); + + // The tombstone keeps answering after uninstall (existing plugin_block + // nodes still render something meaningful); never-installed ids 404. + await api().delete('/api/v1/admin/plugins/fally').set('Cookie', adminCookie).expect(204); + const gone = await api() + .get('/api/v1/plugins/fally/fallback') + .set('Cookie', outsiderCookie) + .expect(200); + expect(gone.body.fallback).toEqual({ type: 'text', value: '[Diagramm]' }); + await api() + .get('/api/v1/plugins/never-there/fallback') + .set('Cookie', outsiderCookie) + .expect(404); + }); + it('refuses uninstall while required, then removes files and marks it removed', async () => { await api() .post('/api/v1/admin/plugins') diff --git a/apps/api/src/plugins/plugins.service.ts b/apps/api/src/plugins/plugins.service.ts index 4f15266..010857b 100644 --- a/apps/api/src/plugins/plugins.service.ts +++ b/apps/api/src/plugins/plugins.service.ts @@ -2,7 +2,12 @@ import { Injectable } from '@nestjs/common'; import { Plugin, PluginInstanceMode as DbPluginMode, Prisma } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk'; -import type { PluginInstanceMode, PluginView, PondPluginSetting } from '@dorfteich/shared'; +import type { + PluginFallbackView, + PluginInstanceMode, + PluginView, + PondPluginSetting, +} from '@dorfteich/shared'; import { ClockService } from '../common/clock.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -224,6 +229,30 @@ export class PluginsService { this.logger.info({ plugin: pluginId, pond: pondId, enabled }, 'audit: pond plugin toggled'); } + /** + * The stored-manifest fallback a `plugin_block` renders while its plugin is + * inactive (issue #76). Looks past `removedAt` on purpose: the manifest row + * is the uninstall tombstone (data-model.md), so blocks referencing a gone + * plugin still resolve a name and text. An image fallback is served from the + * plugin's assets and thus only resolvable while those files exist. + */ + async fallbackFor(pluginId: string): Promise { + const plugin = await this.prisma.plugin.findUnique({ where: { id: pluginId } }); + if (!plugin) return null; + const manifest = plugin.manifest as unknown as PluginManifest; + const declared = manifest.fallback ?? null; + let fallback: PluginFallbackView['fallback'] = null; + if (declared?.type === 'text') { + fallback = declared; + } else if (declared?.type === 'image' && plugin.removedAt === null) { + fallback = { + type: 'image', + url: `/api/v1/plugins/${plugin.id}/${plugin.version}/${declared.value}`, + }; + } + return { pluginId: plugin.id, name: plugin.name, fallback }; + } + /** All installed (non-removed) plugins, for the Site Admin list (#72). */ async list(): Promise { const plugins = await this.prisma.plugin.findMany({ diff --git a/apps/web/e2e/plugin-blocks.spec.ts b/apps/web/e2e/plugin-blocks.spec.ts new file mode 100644 index 0000000..e73da01 --- /dev/null +++ b/apps/web/e2e/plugin-blocks.spec.ts @@ -0,0 +1,264 @@ +import { strToU8, zipSync } from 'fflate'; +import { expect, test } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; + +import { contextForUser } from './helpers'; +import { BLOCK_PLUGIN_SOURCE, blockManifest } from './plugin-fixtures'; + +/** + * Block plugins end to end (issue #76): the fixture block plugin (see + * plugin-fixtures.ts) is installed as `required`, then driven through the + * acceptance criteria — insert → edit → reload round-trip, live collab + * updates, disable→fallback→re-enable without document mutation, and + * copy/paste within and across pages. Selectors are language-neutral + * (classes and iframe content, not labels). + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; +const PLUGIN_ID = 'e2e-block'; +const PLUGIN_NAME = 'E2E Block'; + +function pluginZip(): Buffer { + return Buffer.from( + zipSync({ + 'manifest.json': strToU8(JSON.stringify(blockManifest(PLUGIN_ID, PLUGIN_NAME))), + 'plugin.js': strToU8(BLOCK_PLUGIN_SOURCE), + }), + ); +} + +async function installAsRequired(admin: BrowserContext): Promise { + await admin.request.patch(`/api/v1/admin/plugins/${PLUGIN_ID}/mode`, { + data: { mode: 'disabled' }, + }); + await admin.request.delete(`/api/v1/admin/plugins/${PLUGIN_ID}`); + const installed = await admin.request.post('/api/v1/admin/plugins', { + multipart: { + file: { name: `${PLUGIN_ID}.zip`, mimeType: 'application/zip', buffer: pluginZip() }, + }, + }); + expect(installed.status(), await installed.text()).toBe(201); + const mode = await admin.request.patch(`/api/v1/admin/plugins/${PLUGIN_ID}/mode`, { + data: { mode: 'required' }, + }); + expect(mode.status(), await mode.text()).toBe(200); +} + +async function setMode(admin: BrowserContext, mode: string): Promise { + const res = await admin.request.patch(`/api/v1/admin/plugins/${PLUGIN_ID}/mode`, { + data: { mode }, + }); + expect(res.status(), await res.text()).toBe(200); +} + +async function personalPond(context: BrowserContext): Promise<{ id: string; slug: string }> { + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + return { id: pond.id, slug: pond.slug }; +} + +async function createPage( + context: BrowserContext, + pondId: string, + title: string, +): Promise<{ id: string; slug: string }> { + const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, { + data: { title }, + }); + return created.json(); +} + +/** Opens the page in edit mode and waits for the live connection. */ +async function openEditor(context: BrowserContext, pondSlug: string, slug: string): Promise { + const page = await context.newPage(); + await page.goto(`/p/${pondSlug}/${slug}`); + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); + await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', { + timeout: 15000, + }); + return page; +} + +/** The rendered body of the block plugin's sandbox frame on `page`. */ +function frameBody(page: Page, nth = 0) { + return page.frameLocator(`.plugin-block iframe >> nth=${nth}`).locator('body'); +} + +async function insertBlock(page: Page): Promise { + await page.locator('.editor-toolbar__block-select').selectOption(`${PLUGIN_ID}/note`); + await expect(page.locator('.plugin-block__surface')).toHaveAttribute('data-state', 'ready', { + timeout: 10000, + }); +} + +test('a block round-trips: insert → edit data → reload → render', async ({ browser }) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + await installAsRequired(admin); + const pond = await personalPond(admin); + const created = await createPage(admin, pond.id, `E2E Block Roundtrip ${Date.now()}`); + + const page = await openEditor(admin, pond.slug, created.slug); + await insertBlock(page); + await expect(frameBody(page)).toHaveText('block:empty', { timeout: 10000 }); + + // The edit affordance switches the frame to edit mode; the fixture persists + // "+e" through blockData.setData (→ node attrs → Yjs document). + await page.locator('.plugin-block__bar button').click(); + await expect(frameBody(page)).toHaveText('editing:+e'); + await page.locator('.plugin-block__bar button').click(); + await expect(frameBody(page)).toHaveText('block:+e'); + + // Persisted: a fresh load (read mode) renders the stored data. + await page.reload(); + await expect(frameBody(page)).toHaveText('block:+e', { timeout: 15000 }); + + await admin.close(); +}); + +test('two collaborating users see block-data changes live', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + await installAsRequired(admin); + const pond = await personalPond(owner); + const created = await createPage(owner, pond.id, `E2E Block Collab ${Date.now()}`); + + const pageA = await openEditor(owner, pond.slug, created.slug); + const pageB = await openEditor(admin, pond.slug, created.slug); + + await insertBlock(pageA); + // The block arrives at the collaborator and renders through their sandbox. + await expect(frameBody(pageB)).toHaveText('block:empty', { timeout: 15000 }); + + // A data change by one user re-renders the other user's frame live. + await pageA.locator('.plugin-block__bar button').click(); + await expect(frameBody(pageA)).toHaveText('editing:+e'); + await expect(frameBody(pageB)).toHaveText('block:+e', { timeout: 10000 }); + + await owner.close(); + await admin.close(); +}); + +test('disabling renders the fallback without document mutation; re-enabling restores', async ({ + browser, +}) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + await installAsRequired(admin); + const pond = await personalPond(admin); + const created = await createPage(admin, pond.id, `E2E Block Fallback ${Date.now()}`); + + const page = await openEditor(admin, pond.slug, created.slug); + await insertBlock(page); + await page.locator('.plugin-block__bar button').click(); + await expect(frameBody(page)).toHaveText('editing:+e'); + + // Give the collab autosave a moment to persist before reloading around the + // mode switch (the export below reads the server-side cache). + await page.locator('.plugin-block__bar button').click(); + await expect(frameBody(page)).toHaveText('block:+e'); + + await setMode(admin, 'disabled'); + await page.reload(); + const fallback = page.locator('.plugin-block__fallback'); + await expect(fallback).toContainText(`[${PLUGIN_NAME}]`, { timeout: 15000 }); + + // The document itself still carries the block with its data (no mutation): + // the markdown export keeps the reserved fence and the edited payload. + await expect + .poll( + async () => { + const md = await admin.request.get(`/api/v1/pages/${created.id}/export/markdown`); + return md.text(); + }, + { timeout: 15000 }, + ) + .toContain('dorfteich-plugin'); + const markdown = await ( + await admin.request.get(`/api/v1/pages/${created.id}/export/markdown`) + ).text(); + expect(markdown).toContain('"text":"+e"'); + + // Re-enabling brings the live surface back. + await setMode(admin, 'required'); + await page.reload(); + await expect(frameBody(page)).toHaveText('block:+e', { timeout: 15000 }); + + await admin.close(); +}); + +test('copy/paste preserves block data within and across pages', async ({ browser }) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + await admin.grantPermissions(['clipboard-read', 'clipboard-write']); + await installAsRequired(admin); + const pond = await personalPond(admin); + const pageOne = await createPage(admin, pond.id, `E2E Block Copy A ${Date.now()}`); + const pageTwo = await createPage(admin, pond.id, `E2E Block Copy B ${Date.now()}`); + + const page = await openEditor(admin, pond.slug, pageOne.slug); + // A text anchor above the block gives the later paste a reliable cursor + // home (clicking an atom node only ever yields a node selection). + await page.locator('.ProseMirror').click(); + await page.keyboard.type('anchor'); + await insertBlock(page); + await page.locator('.plugin-block__bar button').click(); + await expect(frameBody(page)).toHaveText('editing:+e'); + await page.locator('.plugin-block__bar button').click(); + await expect(frameBody(page)).toHaveText('block:+e'); + + // Select the block node (clicks inside the sandbox iframe never reach the + // editor — the bar is host DOM) and copy it: the markdown clipboard + // serializer puts the reserved fence with the data JSON on text/plain. + await page.locator('.plugin-block__bar').click(); + await expect(page.locator('.plugin-block')).toHaveClass(/plugin-block--selected/); + // Synthetic copy event: ProseMirror serializes the node selection into the + // event's DataTransfer (native clipboard keys need editor focus, which the + // non-editable bar click does not grant in headless Chromium). + const clipboardText = await page.evaluate(() => { + const dataTransfer = new DataTransfer(); + document.querySelector('.ProseMirror')!.dispatchEvent( + new ClipboardEvent('copy', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true, + }), + ); + return dataTransfer.getData('text/plain'); + }); + expect(clipboardText).toContain('dorfteich-plugin e2e-block/note'); + expect(clipboardText).toContain('"text":"+e"'); + + // Paste within the same page (synthetic event — headless Chromium does not + // feed the real clipboard into a keyboard paste). ArrowRight first: pasting + // onto the still-selected node would replace it instead of adding one. + const pasteMarkdown = async () => { + await page.evaluate((text) => { + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', text); + document.querySelector('.ProseMirror')!.dispatchEvent( + new ClipboardEvent('paste', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true, + }), + ); + }, clipboardText); + }; + // Park the cursor in the anchor text (a real text click focuses the editor + // and clears the node selection — pasting onto the selected node would + // replace it instead of adding one), then paste. + await page.locator('.ProseMirror p', { hasText: 'anchor' }).click(); + await page.keyboard.press('End'); + await pasteMarkdown(); + await expect(page.locator('.plugin-block')).toHaveCount(2); + await expect(frameBody(page, 1)).toHaveText('block:+e', { timeout: 10000 }); + + // Paste across pages of the same pond. + await page.goto(`/p/${pond.slug}/${pageTwo.slug}`); + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + const editor = page.locator('.ProseMirror'); + await expect(editor).toHaveAttribute('contenteditable', 'true'); + await editor.click(); + await pasteMarkdown(); + await expect(frameBody(page)).toHaveText('block:+e', { timeout: 10000 }); + + await admin.close(); +}); diff --git a/apps/web/e2e/plugin-fixtures.ts b/apps/web/e2e/plugin-fixtures.ts index 37dc66f..6ac4f11 100644 --- a/apps/web/e2e/plugin-fixtures.ts +++ b/apps/web/e2e/plugin-fixtures.ts @@ -36,6 +36,60 @@ export function fixtureManifest( }; } +/** Manifest of a block-type plugin (issue #76): one `block` extension point, + * `blockData` to persist, `ui` to resize, and a text fallback for when it is + * disabled while its blocks still exist in documents. */ +export function blockManifest(id: string, name: string): Record { + return { + id, + name, + version: '1.0.0', + apiVersion: '1', + kind: 'code', + extensionPoints: [{ type: 'block', id: 'note', title: { de: name, en: name } }], + permissions: ['blockData', 'ui'], + fallback: { type: 'text', value: `[${name}]` }, + license: 'MIT', + }; +} + +/** + * Block fixture behavior (issue #76), deterministic for assertions: + * - `render` shows `block:`; + * - `edit` appends `+e` to the stored text via `blockData.setData` (a real + * host round-trip that lands in the node attrs) and shows `editing:`. + */ +export const BLOCK_PLUGIN_SOURCE = ` +const PROTOCOL = 'dorfteich.plugin.rpc/1'; +let seq = 0; +function respond(id) { + window.parent.postMessage({ protocol: PROTOCOL, type: 'response', id, ok: true }, '*'); +} +function call(method, params) { + seq += 1; + window.parent.postMessage( + { protocol: PROTOCOL, type: 'request', id: 'blk-' + seq, method, params }, + '*', + ); +} +window.addEventListener('message', (event) => { + const msg = event.data; + if (!msg || msg.protocol !== PROTOCOL || msg.type !== 'request') return; + const data = (msg.params && msg.params.data) || {}; + if (msg.method === 'render') { + document.body.textContent = 'block:' + (data.text || 'empty'); + respond(msg.id); + } else if (msg.method === 'edit') { + const next = { text: (data.text || '') + '+e' }; + call('setData', next); + document.body.textContent = 'editing:' + next.text; + respond(msg.id); + } else if (msg.method === 'destroy') { + respond(msg.id); + } +}); +`; + /** Answers `render`, shows a marker, and resizes its frame via `ui.resize`. */ export const WELL_BEHAVED_SOURCE = ` const PROTOCOL = 'dorfteich.plugin.rpc/1'; diff --git a/apps/web/src/editor/PluginBlockMenu.tsx b/apps/web/src/editor/PluginBlockMenu.tsx new file mode 100644 index 0000000..cc258a5 --- /dev/null +++ b/apps/web/src/editor/PluginBlockMenu.tsx @@ -0,0 +1,62 @@ +import type { Editor } from '@tiptap/core'; +import { useTranslation } from 'react-i18next'; + +import type { PluginBlockOption } from '../plugins/use-pond-plugins'; + +/** The option's label in the UI language, falling back through English to the + * raw block-type id (a manifest always carries de and en). */ +function optionLabel(option: PluginBlockOption, language: string): string { + const base = language.split('-')[0] ?? language; + return option.title[base] ?? option.title.en ?? option.blockType; +} + +/** + * Toolbar control inserting plugin-owned blocks (issue #76): a picker over + * the block types the pond's active code plugins declare. Hidden entirely + * when none are active. Implemented as a select that snaps back to its + * placeholder — insertion is an action, not a persistent choice. + */ +export function PluginBlockMenu({ + editor, + options, +}: { + editor: Editor; + options: PluginBlockOption[]; +}): React.JSX.Element | null { + const { t, i18n } = useTranslation('editor'); + + if (options.length === 0) return null; + + function insert(key: string): void { + const [pluginId, blockType] = key.split('/'); + if (!pluginId || !blockType) return; + editor.chain().focus().insertPluginBlock({ pluginId, blockType }).run(); + } + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/editor/Toolbar.tsx b/apps/web/src/editor/Toolbar.tsx index c8addc2..ae09ea7 100644 --- a/apps/web/src/editor/Toolbar.tsx +++ b/apps/web/src/editor/Toolbar.tsx @@ -3,9 +3,10 @@ import { useEditorState } from '@tiptap/react'; import { useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import type { SectionStyleOption } from '../plugins/use-pond-plugins'; +import type { PluginBlockOption, SectionStyleOption } from '../plugins/use-pond-plugins'; import { LinkMenu } from './LinkMenu'; +import { PluginBlockMenu } from './PluginBlockMenu'; import { SectionStyleMenu } from './SectionStyleMenu'; interface ToolbarProps { @@ -13,6 +14,9 @@ interface ToolbarProps { /** Section styles offered by the pond's active plugins (issue #75); the * section group is omitted while empty. */ sectionStyles?: SectionStyleOption[]; + /** Block types offered by the pond's active code plugins (issue #76); the + * insert group is omitted while empty. */ + pluginBlocks?: PluginBlockOption[]; } function ToolbarButton({ @@ -79,7 +83,11 @@ 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, sectionStyles = [] }: ToolbarProps): React.JSX.Element { +export function Toolbar({ + editor, + sectionStyles = [], + pluginBlocks = [], +}: ToolbarProps): React.JSX.Element { const { t } = useTranslation('editor'); const state = useEditorState({ editor, @@ -213,6 +221,8 @@ export function Toolbar({ editor, sectionStyles = [] }: ToolbarProps): React.JSX + +
{ + documentPluginBlock: { + /** Insert a fresh block owned by a plugin (issue #76). */ + insertPluginBlock: (attrs: { pluginId: string; blockType: string }) => ReturnType; + }; + } +} + +type BlockMode = 'render' | 'edit'; + +/** + * The live surface of one plugin block (issue #76): a sandboxed iframe (#73) + * driven through the plugin's `render`/`edit` lifecycle. Data flows two ways — + * the plugin persists through the `blockData` capability into the node attrs + * (a normal editor transaction, so Yjs replicates it), and attrs changed by a + * collaborator re-invoke the current lifecycle so the frame follows live. + */ +function ActivePluginBlock({ + plugin, + blockType, + data, + editable, + updateData, +}: { + plugin: PluginView; + blockType: string; + data: unknown; + editable: boolean; + updateData: (data: unknown) => void; +}): React.JSX.Element { + const { t, i18n } = useTranslation('plugins'); + const scope = usePluginBlockScope(); + const containerRef = useRef(null); + const sandboxRef = useRef(null); + const [state, setState] = useState('loading'); + const [mode, setMode] = useState('render'); + + // The capability handlers and the data-change effect need current values + // without re-mounting the sandbox; refs carry them across renders. + const dataRef = useRef(data ?? {}); + const editableRef = useRef(editable); + editableRef.current = editable; + const updateDataRef = useRef(updateData); + updateDataRef.current = updateData; + const stateRef = useRef(state); + stateRef.current = state; + const modeRef = useRef(mode); + modeRef.current = mode; + /** JSON of the last write the plugin made itself — its attrs echo must not + * bounce back as a re-render, or typing in the plugin UI would reset. */ + const ownWriteRef = useRef(null); + + const locale = i18n.language; + const dataJson = JSON.stringify(data ?? {}); + + useEffect(() => { + const container = containerRef.current; + if (!container) return undefined; + setState('loading'); + setMode('render'); + const sandbox = createPluginSandbox({ + plugin, + extensionPointId: blockType, + locale, + container, + context: { pageId: scope.pageId, pondId: scope.pondId, openPage: scope.openPage }, + capabilities: { + getData: () => dataRef.current, + setData: (params) => { + if (!editableRef.current) { + throw new Error('the current viewer cannot edit this page'); + } + const value = params && typeof params === 'object' ? params : {}; + dataRef.current = value; + ownWriteRef.current = JSON.stringify(value); + updateDataRef.current(value); + }, + }, + data: dataRef.current, + onStateChange: setState, + }); + sandboxRef.current = sandbox; + return () => { + sandboxRef.current = null; + sandbox.destroy(); + }; + // scope/openPage identity is stable for a mounted page editor; data flows + // through the effect below instead of re-mounting the frame. + }, [plugin.id, plugin.version, blockType, locale]); + + // A data change that did not originate from this frame (a collaborator, or + // undo) re-invokes the current lifecycle so the surface follows live. + useEffect(() => { + if (ownWriteRef.current === dataJson) { + ownWriteRef.current = null; + return; + } + dataRef.current = data ?? {}; + if (stateRef.current !== 'ready') return; + void sandboxRef.current + ?.invoke(modeRef.current, { extensionPointId: blockType, locale, data: dataRef.current }) + .catch(() => undefined); + }, [dataJson]); + + async function switchMode(next: BlockMode): Promise { + setMode(next); + await sandboxRef.current + ?.invoke(next, { extensionPointId: blockType, locale, data: dataRef.current }) + .catch(() => undefined); + } + + return ( +
+
+ {plugin.name} + {editable && state === 'ready' && ( + + )} +
+
+ {state === 'loading' &&

{t('frame.loading')}

} + {state === 'failed' && ( +

+ {t('frame.failed', { name: plugin.name })} +

+ )} +
+ ); +} + +/** + * What a block shows while its plugin is not active for this pond (issue #76): + * the manifest fallback from the stored snapshot — text, a bundled image, or + * the neutral `[plugin/type]` marker when nothing better exists. The document + * itself is never mutated; re-enabling the plugin brings the live surface back. + */ +function PluginBlockFallback({ + pluginId, + blockType, +}: { + pluginId: string; + blockType: string; +}): React.JSX.Element { + const { t } = useTranslation('plugins'); + const query = useQuery({ + queryKey: ['plugin-fallback', pluginId], + queryFn: () => apiGet(`/plugins/${pluginId}/fallback`), + retry: false, + staleTime: 5 * 60 * 1000, + }); + + const fallback = query.data?.fallback ?? null; + return ( +
+ {fallback?.type === 'text' &&

{fallback.value}

} + {fallback?.type === 'image' && {query.data?.name} + {!fallback && ( +

+ [{pluginId}/{blockType}] +

+ )} +

+ {t('block.inactive', { name: query.data?.name ?? pluginId })} +

+
+ ); +} + +function PluginBlockView({ + node, + editor, + selected, + updateAttributes, +}: NodeViewProps): React.JSX.Element { + const scope = usePluginBlockScope(); + const pluginId = node.attrs.pluginId as string; + const blockType = node.attrs.blockType as string; + const plugins = usePondPlugins(scope.pondId); + const plugin = plugins.data?.find((entry) => entry.id === pluginId && entry.kind === 'code'); + + return ( + + {plugin ? ( + updateAttributes({ data })} + /> + ) : plugins.isSuccess ? ( + + ) : null} + + ); +} + +const pluginBlockSpec = nodeSpec('plugin_block'); +export const PluginBlock = Node.create({ + name: 'plugin_block', + group: pluginBlockSpec.group, + atom: pluginBlockSpec.atom, + addAttributes() { + return attributesFromSpec(pluginBlockSpec); + }, + parseHTML: () => pluginBlockSpec.parseDOM, + renderHTML: ({ node }) => pluginBlockSpec.toDOM!(node), + addCommands() { + return { + insertPluginBlock: + (attrs) => + ({ chain }) => + chain() + .insertContent({ type: this.name, attrs: { ...attrs, data: {} } }) + .run(), + }; + }, + addNodeView() { + return ReactNodeViewRenderer(PluginBlockView); + }, +}); diff --git a/apps/web/src/editor/plugin-block-context.tsx b/apps/web/src/editor/plugin-block-context.tsx new file mode 100644 index 0000000..8a26407 --- /dev/null +++ b/apps/web/src/editor/plugin-block-context.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext } from 'react'; + +/** + * The page surface plugin blocks run against (issue #76). Provided by the + * page editor around its `EditorContent`, consumed by the `plugin_block` + * node views — the same React-context route the wikilink node view uses, so + * the TipTap extension needs no per-page configuration and the editor never + * rebuilds when plugin data loads. + */ +export interface PluginBlockScope { + pageId?: string; + pondId?: string; + /** Navigate to a page (backs the `ui.openPage` capability). */ + openPage?: (pageId: string) => void; +} + +export const PluginBlockContext = createContext({}); + +export function usePluginBlockScope(): PluginBlockScope { + return useContext(PluginBlockContext); +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 365968f..b874346 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -28,8 +28,13 @@ 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 { PluginBlockContext } from '../editor/plugin-block-context'; import { SectionStyleSheets } from '../plugins/SectionStyleSheets'; -import { sectionStyleOptions, usePondPlugins } from '../plugins/use-pond-plugins'; +import { + pluginBlockOptions, + sectionStyleOptions, + usePondPlugins, +} from '../plugins/use-pond-plugins'; // A pond loaded offline (no settings) still renders the vision defaults. const DEFAULT_POND_FONTS = { @@ -134,56 +139,81 @@ function PageEditor({ return { targets, resolve: makeWikilinkResolver(targets), pondSlug, editable: canEdit }; }, [pondPages.data, pondSlug, canEdit]); + // The surface plugin blocks run against (#76): ids for the viewer-scoped + // read capabilities, and `ui.openPage` resolved through the pond's page + // list (a plugin only knows page ids; navigation needs the slug). + const pondPagesData = pondPages.data; + const pluginBlockScope = useMemo( + () => ({ + pageId: page.id, + pondId: page.pondId, + openPage: (pageId: string) => { + const target = (pondPagesData ?? []).find((p) => p.id === pageId); + if (target) navigate(`/p/${pondSlug}/${target.slug}`); + }, + }), + [page.id, page.pondId, pondPagesData, pondSlug, navigate], + ); + const blockInserts = useMemo(() => pluginBlockOptions(pondPlugins.data), [pondPlugins.data]); + if (!editor || !ydoc) return <>; return ( -
- - {canEdit && } -
- + +
+ + {canEdit && ( + + )} +
+ +
+ {showAttachments && ( + setShowAttachments(false)} + /> + )} +
+ {t(`connection.${collab.status}`)} +
+ + {collab.localOnly && ( +
+ {t('offline.localOnly')} +
+ )} + {mode === 'edit' && readOnly && ( +
+ {t('readOnly.notice')} +
+ )} + {collab.tooLarge && ( +
+ {t('tooLarge.notice')} +
+ )} + {collab.accessRevoked && ( + + )} + + {canEdit && }
- {showAttachments && ( - setShowAttachments(false)} - /> - )} -
- {t(`connection.${collab.status}`)} -
- - {collab.localOnly && ( -
- {t('offline.localOnly')} -
- )} - {mode === 'edit' && readOnly && ( -
- {t('readOnly.notice')} -
- )} - {collab.tooLarge && ( -
- {t('tooLarge.notice')} -
- )} - {collab.accessRevoked && ( - - )} - - {canEdit && } -
+ ); } diff --git a/apps/web/src/plugins/use-pond-plugins.ts b/apps/web/src/plugins/use-pond-plugins.ts index cda453b..28766bb 100644 --- a/apps/web/src/plugins/use-pond-plugins.ts +++ b/apps/web/src/plugins/use-pond-plugins.ts @@ -37,3 +37,21 @@ export function sectionStyleOptions(plugins: PluginView[] | undefined): SectionS .map((point) => ({ pluginId: plugin.id, styleId: point.id, title: point.title })), ); } + +/** The block types the active `code` plugins offer for insertion (issue #76), + * flattened for the editor's insert menu. */ +export interface PluginBlockOption { + pluginId: string; + blockType: string; + title: Record; +} + +export function pluginBlockOptions(plugins: PluginView[] | undefined): PluginBlockOption[] { + return (plugins ?? []) + .filter((plugin) => plugin.kind === 'code') + .flatMap((plugin) => + plugin.extensionPoints + .filter((point) => point.type === 'block') + .map((point) => ({ pluginId: plugin.id, blockType: point.id, title: point.title })), + ); +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 2545cdc..5b1d48b 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -610,6 +610,53 @@ button { outline-color: var(--color-border); } +/* A plugin-owned block (issue #76): a framed island in the content column. + * The sandbox iframe sizes itself via ui.resize; the bar carries the plugin + * name and the edit affordance. */ +.plugin-block { + margin: 0.75rem 0; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-bg); +} + +.plugin-block--selected { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} + +.plugin-block__bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-1) var(--space-2); + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-subtle); + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.plugin-block__mount .plugin-frame { + display: block; + width: 100%; + border: none; +} + +.plugin-block__fallback { + padding: var(--space-2) var(--space-3); +} + +.plugin-block__fallback-text { + margin: 0; +} + +.plugin-block__fallback-note { + margin: var(--space-1) 0 0; + font-size: 0.8rem; + color: var(--color-text-muted); +} + .toolbar-button { display: inline-flex; align-items: center; diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index 392fc38..75d54e6 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -81,6 +81,10 @@ "label": "Abschnitts-Stil", "none": "Kein Abschnitt", "remove": "Abschnitt auflösen" + }, + "pluginBlock": { + "label": "Plugin-Block einfügen", + "placeholder": "Block einfügen …" } }, "image": { diff --git a/packages/shared/i18n/de/plugins.json b/packages/shared/i18n/de/plugins.json index 3f30c35..aa895a1 100644 --- a/packages/shared/i18n/de/plugins.json +++ b/packages/shared/i18n/de/plugins.json @@ -3,6 +3,11 @@ "loading": "Plugin wird geladen …", "failed": "Das Plugin „{{name}}“ konnte nicht geladen werden." }, + "block": { + "edit": "Bearbeiten", + "done": "Fertig", + "inactive": "Das Plugin „{{name}}“ ist für diesen Teich nicht aktiv." + }, "preview": { "loading": "Plugins werden geladen …", "loadFailed": "Die Plugin-Liste konnte nicht geladen werden.", diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index af7676a..16889db 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -81,6 +81,10 @@ "label": "Section style", "none": "No section", "remove": "Unwrap section" + }, + "pluginBlock": { + "label": "Insert plugin block", + "placeholder": "Insert block …" } }, "image": { diff --git a/packages/shared/i18n/en/plugins.json b/packages/shared/i18n/en/plugins.json index 2bc02aa..0d6b47e 100644 --- a/packages/shared/i18n/en/plugins.json +++ b/packages/shared/i18n/en/plugins.json @@ -3,6 +3,11 @@ "loading": "Loading plugin …", "failed": "The plugin “{{name}}” could not be loaded." }, + "block": { + "edit": "Edit", + "done": "Done", + "inactive": "The plugin “{{name}}” is not active for this pond." + }, "preview": { "loading": "Loading plugins …", "loadFailed": "The plugin list could not be loaded.", diff --git a/packages/shared/src/editor-schema/html.test.ts b/packages/shared/src/editor-schema/html.test.ts index 133357b..7e9321b 100644 --- a/packages/shared/src/editor-schema/html.test.ts +++ b/packages/shared/src/editor-schema/html.test.ts @@ -42,6 +42,20 @@ describe('docToHtml (issue #24)', () => { expect(html).toContain('href="#"'); }); + it('renders a plugin block as a data-carrying placeholder (issue #76)', () => { + const block = editorSchema.nodes.plugin_block.create({ + pluginId: 'mermaid', + blockType: 'diagram', + data: { source: 'A-->B "quoted" ' }, + }); + const html = docToHtml(editorSchema.node('doc', null, [block])); + expect(html).toContain('data-plugin-block="mermaid/diagram"'); + // The data JSON is attribute-escaped — no raw quotes or angle brackets. + expect(html).toContain('"quoted\\"'); + expect(html).not.toContain(''); + expect(html).toContain('[mermaid/diagram]'); + }); + it('renders task list checkboxes with their checked state', () => { const doc = markdownToDoc('- [ ] Todo\n- [x] Done'); const html = docToHtml(doc); diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts index 73bfd4d..494a93f 100644 --- a/packages/shared/src/editor-schema/html.ts +++ b/packages/shared/src/editor-schema/html.ts @@ -114,6 +114,20 @@ function renderBlock(node: Node): string { const styleId = escapeHtml(node.attrs.styleId as string); return `
${renderBlocks(node)}
`; } + case 'plugin_block': { + // A plugin-owned block (#76). The static HTML carries the full state in + // data attributes (same shape as the schema's toDOM, so editor + // copy/paste round-trips) and a neutral `[plugin/type]` label; the SPA + // renders it live through the plugin's sandbox, and office/PDF + // renditions replace it with the manifest fallback (#79). + const pluginId = escapeHtml(node.attrs.pluginId as string); + const blockType = escapeHtml(node.attrs.blockType as string); + const data = escapeHtml(JSON.stringify(node.attrs.data ?? {})); + return ( + `
[${pluginId}/${blockType}]
` + ); + } case 'code_block': return `
${escapeHtml(node.textContent)}
`; case 'horizontal_rule': diff --git a/packages/shared/src/editor-schema/markdown.test.ts b/packages/shared/src/editor-schema/markdown.test.ts index 9e53936..014fdd5 100644 --- a/packages/shared/src/editor-schema/markdown.test.ts +++ b/packages/shared/src/editor-schema/markdown.test.ts @@ -119,4 +119,45 @@ describe('markdown round-trip (issue #24)', () => { expect(outer?.firstChild?.type.name).toBe('section'); expect(outer?.firstChild?.attrs.styleId).toBe('inner'); }); + + it('round-trips a plugin block as a reserved fence (issue #76)', () => { + const md = ['```dorfteich-plugin mermaid/diagram', '{"source":"graph TD; A-->B"}', '```'].join( + '\n', + ); + const doc = markdownToDoc(md); + const block = doc.firstChild; + expect(block?.type.name).toBe('plugin_block'); + expect(block?.attrs).toMatchObject({ + pluginId: 'mermaid', + blockType: 'diagram', + data: { source: 'graph TD; A-->B' }, + }); + const once = docToMarkdown(doc); + expect(docToMarkdown(markdownToDoc(once))).toBe(once); + expect(once).toContain('```dorfteich-plugin mermaid/diagram'); + }); + + it('keeps an ordinary fenced code block untouched by the plugin rule', () => { + const doc = markdownToDoc('```js\nconst x = 1;\n```'); + expect(doc.firstChild?.type.name).toBe('code_block'); + }); + + it('degrades an unparsable plugin-block body to empty data (issue #76)', () => { + const doc = markdownToDoc('```dorfteich-plugin p/t\nnot json at all\n```'); + expect(doc.firstChild?.type.name).toBe('plugin_block'); + expect(doc.firstChild?.attrs.data).toEqual({}); + }); + + it('escalates the fence when the data contains backticks (issue #76)', () => { + const doc = markdownToDoc('```dorfteich-plugin p/t\n{"code":"x"}\n```'); + const withTicks = doc.type.schema.nodes.plugin_block!.create({ + pluginId: 'p', + blockType: 't', + data: { code: 'a ``` fence inside' }, + }); + const md = docToMarkdown(doc.type.schema.nodes.doc!.create(null, [withTicks])); + const reparsed = markdownToDoc(md); + expect(reparsed.firstChild?.type.name).toBe('plugin_block'); + expect(reparsed.firstChild?.attrs.data).toEqual({ code: 'a ``` fence inside' }); + }); }); diff --git a/packages/shared/src/editor-schema/markdown.ts b/packages/shared/src/editor-schema/markdown.ts index 381cfc3..fecd92c 100644 --- a/packages/shared/src/editor-schema/markdown.ts +++ b/packages/shared/src/editor-schema/markdown.ts @@ -64,6 +64,20 @@ function retype(token: Token, type: string): Token { return clone; } +/** Info string of a plugin-block fence: `dorfteich-plugin /`. */ +const PLUGIN_BLOCK_INFO = /^dorfteich-plugin\s+([a-z0-9-]+)\/([a-z0-9-]+)\s*$/; + +/** Parses a plugin block's fence body (its data JSON); anything unparsable + * degrades to empty data — the block itself (plugin + type) survives. */ +function pluginBlockData(body: string): unknown { + try { + const parsed: unknown = JSON.parse(body); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + /** * Rewrites the markdown-it token stream so it matches the editor schema: * - a bullet list where every item carries a `[ ]`/`[x]` marker becomes a @@ -71,7 +85,9 @@ function retype(token: Token, type: string): Token { * lists — GFM does not define their meaning either); * - table cell content (a bare `inline` token in markdown-it) is wrapped * in a synthetic paragraph, matching `table_cell`/`table_header`'s - * `block+` content. + * `block+` content; + * - a fence whose info string is `dorfteich-plugin /` + * becomes a `plugin_block` token, its body carrying the data JSON (#76). */ function transformTokens(tokens: Token[]): Token[] { const out: Token[] = []; @@ -112,6 +128,18 @@ function transformTokens(tokens: Token[]): Token[] { } } + if (tok.type === 'fence') { + const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim()); + if (info) { + const block = retype(tok, 'plugin_block'); + block.attrSet('pluginId', info[1]!); + block.attrSet('blockType', info[2]!); + out.push(block); + i += 1; + continue; + } + } + if (tok.type === 'th_open' || tok.type === 'td_open') { const close = findMatchingClose(tokens, i); out.push(tok); @@ -236,6 +264,14 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), { styleId: tok.attrGet('styleId') ?? '', }), }, + plugin_block: { + node: 'plugin_block', + getAttrs: (tok) => ({ + pluginId: tok.attrGet('pluginId') ?? '', + blockType: tok.attrGet('blockType') ?? '', + data: pluginBlockData(tok.content.trim()), + }), + }, paragraph: { block: 'paragraph' }, list_item: { block: 'list_item' }, task_item: { @@ -334,14 +370,27 @@ const markdownSerializer = new MarkdownSerializer( state.closeBlock(node); }, code_block(state, node) { - const backticks = node.textContent.match(/`{3,}/gm); - const fence = backticks ? `${[...backticks].sort().slice(-1)[0]}\`` : '```'; + const fence = fenceFor(node.textContent); state.write(`${fence}\n`); state.text(node.textContent, false); state.write('\n'); state.write(fence); state.closeBlock(node); }, + plugin_block(state, node) { + // A fence with a reserved info string; the body is the block's data as + // compact JSON (#76). Office/PDF renditions replace this with the + // manifest fallback (#79) — the markdown form is the lossless one. + const pluginId = node.attrs.pluginId as string; + const blockType = node.attrs.blockType as string; + const payload = JSON.stringify(node.attrs.data ?? {}); + const fence = fenceFor(payload); + state.write(`${fence}dorfteich-plugin ${pluginId}/${blockType}\n`); + state.text(payload, false); + state.write('\n'); + state.write(fence); + state.closeBlock(node); + }, heading(state, node) { state.write(`${state.repeat('#', node.attrs.level as number)} `); state.renderInline(node, false); @@ -416,6 +465,12 @@ const markdownSerializer = new MarkdownSerializer( }, ); +/** A fence long enough that `body` cannot terminate it early. */ +function fenceFor(body: string): string { + const backticks = body.match(/`{3,}/gm); + return backticks ? `${[...backticks].sort().slice(-1)[0]}\`` : '```'; +} + function backticksFor(node: Node, side: -1 | 1): string { const text = node.isText ? (node.text ?? '') : ''; const matches = text.match(/`+/g); diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts index e815eb1..a6d3200 100644 --- a/packages/shared/src/editor-schema/schema.ts +++ b/packages/shared/src/editor-schema/schema.ts @@ -7,8 +7,8 @@ import { tableNodes } from 'prosemirror-tables'; * validation (ADR 0008) all import this schema instead of defining their * own, so "valid document" means the same thing everywhere. * - * Node names `wikilink` and `plugin_block` are reserved for later stories - * (wikilinks, plugin-defined block types) — do not repurpose them. + * Node names `wikilink` and `plugin_block` are reserved for these features + * (wikilinks #46, plugin-defined block types #76) — do not repurpose them. */ export const editorSchema = new Schema({ nodes: { @@ -87,6 +87,48 @@ export const editorSchema = new Schema({ toDOM: () => ['pre', ['code', 0]], }, + // A block owned by a code plugin (ADR 0008 extension point `block`, + // issue #76): an atom carrying the owning plugin, its block type, and the + // block's data as a JSON-serializable object. The editor renders it + // through the plugin's sandboxed iframe; everything else (clipboard, + // content cache, exports until #79) uses this DOM shape, whose data + // attributes round-trip the full state — copy/paste never loses data. + plugin_block: { + group: 'block', + atom: true, + attrs: { + pluginId: { validate: 'string' }, + blockType: { validate: 'string' }, + data: { default: {} }, + }, + parseDOM: [ + { + tag: 'div[data-plugin-block]', + getAttrs: (dom) => { + const [pluginId = '', blockType = ''] = ( + dom.getAttribute('data-plugin-block') ?? '' + ).split('/'); + let data: unknown = {}; + try { + data = JSON.parse(dom.getAttribute('data-plugin-data') ?? '{}'); + } catch { + // A hand-edited attribute falls back to empty data; the node + // itself (plugin + type) survives. + } + return { pluginId, blockType, data }; + }, + }, + ], + toDOM: (node) => [ + 'div', + { + 'data-plugin-block': `${node.attrs.pluginId as string}/${node.attrs.blockType as string}`, + 'data-plugin-data': JSON.stringify(node.attrs.data ?? {}), + class: 'dt-plugin-block', + }, + ], + }, + horizontal_rule: { group: 'block', parseDOM: [{ tag: 'hr' }], diff --git a/packages/shared/src/plugins.ts b/packages/shared/src/plugins.ts index 39a90bd..2e0ca19 100644 --- a/packages/shared/src/plugins.ts +++ b/packages/shared/src/plugins.ts @@ -84,6 +84,20 @@ export const pondPluginToggleInputSchema = z.object({ }); export type PondPluginToggleInput = z.infer; +/** + * What a `plugin_block` of an inactive plugin renders instead of its sandbox + * (issue #76): the manifest `fallback`, resolved server-side from the stored + * manifest snapshot — which survives uninstall as a tombstone, so blocks in + * documents always have something to show. An image fallback is resolved to + * its served URL while the files exist and degrades to `null` (neutral + * placeholder) once they are gone. + */ +export interface PluginFallbackView { + pluginId: string; + name: string; + fallback: { type: 'text'; value: string } | { type: 'image'; url: string } | null; +} + /** * Responses of the viewer-scoped plugin API (issue #74, `/api/v1/plugin/…`). * Every call runs with the requesting user's session behind the standard