diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cc43169..dce25da 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -351,6 +351,33 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/plugin-admin.spec.ts + # Several contexts per test across these two packs → reset first. + - name: Reset login rate limit before plugin feature packs + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run section-styles pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/section-styles.spec.ts + + - name: Run plugin blocks pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/plugin-blocks.spec.ts + + # Owner + admin + restricted viewer contexts → reset first. + - name: Reset login rate limit before page-tools pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run page-tools pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/page-tools.spec.ts + - name: Dump server logs on failure if: failure() run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 1af8998..04370b1 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -6,6 +6,7 @@ import { PageListItemView, PageStateView, PageView, + PluginPageSummary, RepositionPageInput, SidebarSortMode, UpdatePageInput, @@ -130,6 +131,26 @@ export class PagesService { })); } + /** `readPond.listPages` for the plugin API (issue #77): the viewer-readable + * pages with their label *names* — pageTool plugins filter on these and + * never see ids they could not resolve anyway. */ + async pluginPageSummaries(user: User, pondId: string): Promise { + const [pages, labels] = await Promise.all([ + this.list(user, pondId), + this.prisma.label.findMany({ where: { pondId }, select: { id: true, name: true } }), + ]); + const nameById = new Map(labels.map((label) => [label.id, label.name])); + return pages.map((page) => ({ + id: page.id, + title: page.title, + slug: page.slug, + labels: page.labelIds + .map((id) => nameById.get(id)) + .filter((name): name is string => Boolean(name)) + .sort(), + })); + } + async create(user: User, pondId: string, input: CreatePageInput): Promise { const page = await this.insertPage(user, pondId, input.title, emptyPageState()); return this.viewOf(page); diff --git a/apps/api/src/pages/plugin-api.controller.ts b/apps/api/src/pages/plugin-api.controller.ts index 908dc4e..7a8a476 100644 --- a/apps/api/src/pages/plugin-api.controller.ts +++ b/apps/api/src/pages/plugin-api.controller.ts @@ -20,15 +20,15 @@ import { PagesService } from './pages.service'; export class PluginApiController { constructor(private readonly pages: PagesService) {} - /** `readPond.listPages` — the pages of a pond the viewer may read. */ + /** `readPond.listPages` — the pages of a pond the viewer may read, with + * label names for pageTool filtering (issue #77). */ @Get('ponds/:pondId/pages') @RequiresPondRole('reader', { idParam: 'pondId' }) // the service filters per page - async listPages( + listPages( @Param('pondId') pondId: string, @Req() request: AuthedRequest, ): Promise { - const pages = await this.pages.list(request.user!, pondId); - return pages.map((page) => ({ id: page.id, title: page.title, slug: page.slug })); + return this.pages.pluginPageSummaries(request.user!, pondId); } /** `readCurrentPage.getOutline` / `readPond.getPageOutline`. */ diff --git a/apps/api/src/pages/plugin-api.e2e.db.test.ts b/apps/api/src/pages/plugin-api.e2e.db.test.ts index 25d90fb..bd71d32 100644 --- a/apps/api/src/pages/plugin-api.e2e.db.test.ts +++ b/apps/api/src/pages/plugin-api.e2e.db.test.ts @@ -147,6 +147,10 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => { [openPageId, secretPageId].sort(), ); expect(res.body[0]).toMatchObject({ title: expect.any(String), slug: expect.any(String) }); + // Label *names* travel with each summary (issue #77, page-index filter). + const byId = new Map(res.body.map((p: { id: string; labels: string[] }) => [p.id, p.labels])); + expect(byId.get(secretPageId)).toEqual([`secret-${suffix}`]); + expect(byId.get(openPageId)).toEqual([]); }); it('filters listPages for a label-restricted reader and 404s the hidden page', async () => { diff --git a/apps/api/src/plugins/plugins.e2e.db.test.ts b/apps/api/src/plugins/plugins.e2e.db.test.ts index 493070c..20e82db 100644 --- a/apps/api/src/plugins/plugins.e2e.db.test.ts +++ b/apps/api/src/plugins/plugins.e2e.db.test.ts @@ -67,6 +67,11 @@ describe.skipIf(!hasTestDb)('plugins install (e2e, issue #71)', () => { beforeAll(async () => { prisma = createTestPrisma(); + // A local dev DB is shared with the e2e stack, which installs the real + // reference plugins (`toc`, …) — clear the registry up front so the + // fixture installs below never collide with a leftover active version. + await prisma.pondPlugin.deleteMany({}); + await prisma.plugin.deleteMany({}); app = await createTestApp(); storage = app.get(PluginStorageService); watcher = app.get(PluginWatcherService); diff --git a/apps/web/e2e/page-tools.spec.ts b/apps/web/e2e/page-tools.spec.ts new file mode 100644 index 0000000..b19f547 --- /dev/null +++ b/apps/web/e2e/page-tools.spec.ts @@ -0,0 +1,233 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { expect, test } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * pageTool plugins end to end (issue #77): installs the real `toc` and + * `page-index` reference packages (built by `pnpm build` into + * packages/plugins//dist/-.zip) and drives the acceptance + * criteria — live outline updates after the persistence debounce, scroll on + * heading click, permission-filtered page index, navigation via `ui.openPage`, + * and both surfaces working as panel tools and embedded blocks. + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +/** Generous ceiling over collab persistence debounce (2 s) + the toc's 5 s poll. */ +const OUTLINE_TIMEOUT = 20000; + +function referenceZip(id: string): Buffer { + const here = dirname(fileURLToPath(import.meta.url)); + return readFileSync(join(here, `../../../packages/plugins/${id}/dist/${id}-1.0.0.zip`)); +} + +async function installAsRequired(admin: BrowserContext, id: string): Promise { + await admin.request.patch(`/api/v1/admin/plugins/${id}/mode`, { data: { mode: 'disabled' } }); + await admin.request.delete(`/api/v1/admin/plugins/${id}`); + const installed = await admin.request.post('/api/v1/admin/plugins', { + multipart: { + file: { name: `${id}.zip`, mimeType: 'application/zip', buffer: referenceZip(id) }, + }, + }); + expect(installed.status(), await installed.text()).toBe(201); + const mode = await admin.request.patch(`/api/v1/admin/plugins/${id}/mode`, { + data: { mode: 'required' }, + }); + expect(mode.status(), await mode.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(); +} + +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; +} + +/** Opens the page-tools panel and expands one tool, returning its frame body. */ +async function openTool(page: Page, toolKey: string) { + await page.locator('.editor-shell__page-tools-toggle').click(); + await page.locator(`.page-tools__tool[data-tool="${toolKey}"] .page-tools__toggle`).click(); + const host = page.locator(`.page-tools__tool[data-tool="${toolKey}"] .plugin-frame-host`); + await expect(host).toHaveAttribute('data-state', 'ready', { timeout: 10000 }); + return page.frameLocator(`.page-tools__tool[data-tool="${toolKey}"] iframe`).locator('body'); +} + +test('toc panel: outline appears, follows live edits, and click scrolls', async ({ browser }) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + await installAsRequired(admin, 'toc'); + const pond = await personalPond(admin); + const created = await createPage(admin, pond.id, `E2E Toc ${Date.now()}`); + + const page = await openEditor(admin, pond.slug, created.slug); + const editor = page.locator('.ProseMirror'); + await editor.click(); + await page.keyboard.type('# Alpha heading'); + await page.keyboard.press('Enter'); + // Enough body text that the second heading sits below the fold. + for (let i = 0; i < 40; i += 1) { + await page.keyboard.type(`filler paragraph ${i}`); + await page.keyboard.press('Enter'); + } + await page.keyboard.type('## Omega heading'); + + const toc = await openTool(page, 'toc/toc'); + await expect(toc).toContainText('Alpha heading', { timeout: OUTLINE_TIMEOUT }); + await expect(toc).toContainText('Omega heading', { timeout: OUTLINE_TIMEOUT }); + + // Live heading edits arrive after the persistence debounce + poll. + await editor.click(); + await page.keyboard.press('ControlOrMeta+End'); + await page.keyboard.press('Enter'); + await page.keyboard.type('## Freshly added'); + await expect(toc).toContainText('Freshly added', { timeout: OUTLINE_TIMEOUT }); + + // Clicking an entry scrolls the host content to that heading. Typing left + // the view at the bottom — first jump to the top heading, then to Omega. + const alpha = editor.locator('h1', { hasText: 'Alpha heading' }); + const omega = editor.locator('h2', { hasText: 'Omega heading' }); + await toc.locator('a', { hasText: 'Alpha heading' }).click(); + await expect(alpha).toBeInViewport({ timeout: 5000 }); + await expect(omega).not.toBeInViewport(); + await toc.locator('a', { hasText: 'Omega heading' }).click(); + await expect(omega).toBeInViewport({ timeout: 5000 }); + + // The same surface also works embedded as a plugin_block (issue #77 AC). + await editor.click(); + await page.locator('.editor-toolbar__block-select').selectOption('toc/toc'); + await expect(page.locator('.plugin-block .plugin-block__surface')).toHaveAttribute( + 'data-state', + 'ready', + { timeout: 10000 }, + ); + await expect(page.frameLocator('.plugin-block iframe').locator('body')).toContainText( + 'Alpha heading', + { timeout: OUTLINE_TIMEOUT }, + ); + + await admin.close(); +}); + +test('page-index as embedded block lists pages and navigates on click', async ({ browser }) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + await installAsRequired(admin, 'page-index'); + const pond = await personalPond(admin); + const stamp = Date.now(); + const target = await createPage(admin, pond.id, `E2E Index Target ${stamp}`); + const home = await createPage(admin, pond.id, `E2E Index Home ${stamp}`); + + const page = await openEditor(admin, pond.slug, home.slug); + // Embed the pageTool as a plugin_block via the insert menu (issue #77 AC). + await page.locator('.editor-toolbar__block-select').selectOption('page-index/page-index'); + const host = page.locator('.plugin-block .plugin-block__surface'); + await expect(host).toHaveAttribute('data-state', 'ready', { timeout: 10000 }); + + const frame = page.frameLocator('.plugin-block iframe').locator('body'); + await expect(frame.locator('a', { hasText: `E2E Index Target ${stamp}` })).toBeVisible({ + timeout: 10000, + }); + + // `ui.openPage` navigates the host to the clicked page. + await frame.locator('a', { hasText: `E2E Index Target ${stamp}` }).click(); + await page.waitForURL(`**/p/${pond.slug}/${target.slug}`, { timeout: 10000 }); + + await admin.close(); +}); + +test('page-index respects the viewer permissions (label-restricted reader)', async ({ + browser, +}) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const viewer = await contextForUser(browser, BASE_URL, 'fixture-viewer'); + await installAsRequired(admin, 'page-index'); + + // The owner's pond: an open page, a secret-labeled page, and a reader who + // may read the pond but is denied the secret label (permissions.md). + const pond = await personalPond(owner); + const stamp = Date.now(); + const open = await createPage(owner, pond.id, `E2E Perm Open ${stamp}`); + const secret = await createPage(owner, pond.id, `E2E Perm Secret ${stamp}`); + const label = await ( + await owner.request.post(`/api/v1/ponds/${pond.id}/labels`, { + data: { name: `e2e-secret-${stamp}` }, + }) + ).json(); + await owner.request.post(`/api/v1/pages/${secret.id}/labels`, { + data: { labelId: label.id }, + }); + const viewerId = ((await (await viewer.request.get('/api/v1/auth/me')).json()) as { id: string }) + .id; + const allow = await owner.request.post(`/api/v1/ponds/${pond.id}/grants`, { + data: { + subjectType: 'user', + subjectId: viewerId, + role: 'reader', + scopeType: 'pond', + effect: 'allow', + }, + }); + // 409 = the pond-wide reader grant survived an earlier run — same effect. + expect([201, 409], await allow.text()).toContain(allow.status()); + const deny = await owner.request.post(`/api/v1/ponds/${pond.id}/grants`, { + data: { + subjectType: 'user', + subjectId: viewerId, + role: 'reader', + scopeType: 'label', + scopeId: label.id, + effect: 'deny', + }, + }); + expect(deny.status(), await deny.text()).toBe(201); + + // The restricted reader's page index shows the open page, never the secret. + const page = await viewer.newPage(); + await page.goto(`/p/${pond.slug}/${open.slug}`); + await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 15000 }); + const index = await openTool(page, 'page-index/page-index'); + await expect(index.locator('a', { hasText: `E2E Perm Open ${stamp}` })).toBeVisible({ + timeout: 10000, + }); + await expect(index.locator('a', { hasText: `E2E Perm Secret ${stamp}` })).toHaveCount(0); + + // The owner sees both, and can narrow by the label filter chip. + const ownerPage = await owner.newPage(); + await ownerPage.goto(`/p/${pond.slug}/${open.slug}`); + await expect(ownerPage.locator('.ProseMirror')).toBeVisible({ timeout: 15000 }); + const ownerIndex = await openTool(ownerPage, 'page-index/page-index'); + await expect(ownerIndex.locator('a', { hasText: `E2E Perm Secret ${stamp}` })).toBeVisible({ + timeout: 10000, + }); + await ownerIndex.locator(`button[data-filter="e2e-secret-${stamp}"]`).click(); + await expect(ownerIndex.locator('a', { hasText: `E2E Perm Open ${stamp}` })).toHaveCount(0); + await expect(ownerIndex.locator('a', { hasText: `E2E Perm Secret ${stamp}` })).toBeVisible(); + + await owner.close(); + await admin.close(); + await viewer.close(); +}); diff --git a/apps/web/src/editor/plugin-block-context.tsx b/apps/web/src/editor/plugin-block-context.tsx index 8a26407..1cfc42f 100644 --- a/apps/web/src/editor/plugin-block-context.tsx +++ b/apps/web/src/editor/plugin-block-context.tsx @@ -12,6 +12,8 @@ export interface PluginBlockScope { pondId?: string; /** Navigate to a page (backs the `ui.openPage` capability). */ openPage?: (pageId: string) => void; + /** Scroll the content to a heading by outline id (`ui.scrollToHeading`, #77). */ + scrollToHeading?: (headingId: string) => void; } export const PluginBlockContext = createContext({}); diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index b874346..65f3ee5 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_FONTS } from '@dorfteich/shared'; +import { DEFAULT_FONTS, extractOutline } from '@dorfteich/shared'; import type { PageListItemView, PageStateView, PondView } from '@dorfteich/shared'; import { useQuery } from '@tanstack/react-query'; import { Collaboration } from '@tiptap/extension-collaboration'; @@ -29,6 +29,7 @@ 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 { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel'; import { SectionStyleSheets } from '../plugins/SectionStyleSheets'; import { pluginBlockOptions, @@ -67,6 +68,7 @@ function PageEditor({ const { user } = useAuth(); const navigate = useNavigate(); const [showAttachments, setShowAttachments] = useState(false); + const [showPageTools, setShowPageTools] = useState(false); // Created and destroyed within the same effect (not `useMemo` + a separate // cleanup effect): React StrictMode's dev-only mount→cleanup→remount would @@ -151,8 +153,18 @@ function PageEditor({ const target = (pondPagesData ?? []).find((p) => p.id === pageId); if (target) navigate(`/p/${pondSlug}/${target.slug}`); }, + // Outline ids are derived from the doc (extractOutline), never stamped + // into the DOM — so resolve the id to its heading *position* and scroll + // the matching rendered heading (#77). + scrollToHeading: (headingId: string) => { + if (!editor) return; + const index = extractOutline(editor.state.doc).findIndex((entry) => entry.id === headingId); + if (index < 0) return; + const headings = editor.view.dom.querySelectorAll('h1, h2, h3, h4'); + headings[index]?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, }), - [page.id, page.pondId, pondPagesData, pondSlug, navigate], + [page.id, page.pondId, pondPagesData, pondSlug, navigate, editor], ); const blockInserts = useMemo(() => pluginBlockOptions(pondPlugins.data), [pondPlugins.data]); @@ -175,7 +187,20 @@ function PageEditor({ > {t('files:title')} + {hasPageTools(pondPlugins.data) && ( + + )} + {showPageTools && ( + + )} {showAttachments && ( ; +} + +function toolEntries(plugins: PluginView[] | undefined): PageToolEntry[] { + return (plugins ?? []) + .filter((plugin) => plugin.kind === 'code') + .flatMap((plugin) => + plugin.extensionPoints + .filter((point) => point.type === 'pageTool') + .map((point) => ({ plugin, pointId: point.id, title: point.title })), + ); +} + +/** Whether the pond has any pageTool surface — gates the panel's toggle. */ +export function hasPageTools(plugins: PluginView[] | undefined): boolean { + return toolEntries(plugins).length > 0; +} + +function titleFor(entry: PageToolEntry, language: string): string { + const base = language.split('-')[0] ?? language; + return entry.title[base] ?? entry.title.en ?? entry.pointId; +} + +/** + * The page's tools panel (issue #77): every active `pageTool` plugin surface + * of the pond, each behind a disclosure — its sandbox iframe mounts only when + * opened for the first time (lazy, ADR 0008 "pageTool widgets are + * lazy-loaded") and unmounts when closed, tearing the frame down. + */ +export function PageToolsPanel({ + plugins, + context, +}: { + plugins: PluginView[] | undefined; + context: PluginHostContext; +}): React.JSX.Element | null { + const { i18n } = useTranslation('plugins'); + const [open, setOpen] = useState>({}); + + const entries = toolEntries(plugins); + if (entries.length === 0) return null; + + return ( + + ); +} diff --git a/apps/web/src/plugins/host-capabilities.ts b/apps/web/src/plugins/host-capabilities.ts index c6158b9..4a2efdf 100644 --- a/apps/web/src/plugins/host-capabilities.ts +++ b/apps/web/src/plugins/host-capabilities.ts @@ -21,6 +21,9 @@ export interface PluginHostContext { openPage?: (pageId: string) => void; /** Show a localized toast (the `ui.toast` capability). */ toast?: (messageKey: string) => void; + /** Scroll the page content to a heading by its outline id (`ui.scrollToHeading`, + * issue #77 — the TOC reference plugin's click target). */ + scrollToHeading?: (headingId: string) => void; } /** @@ -62,6 +65,9 @@ export function buildHostCapabilities(context: PluginHostContext): HostCapabilit toast: (params) => { if (typeof params === 'string') context.toast?.(params); }, + scrollToHeading: (params) => { + if (typeof params === 'string' && params.length > 0) context.scrollToHeading?.(params); + }, }; } diff --git a/apps/web/src/plugins/use-pond-plugins.ts b/apps/web/src/plugins/use-pond-plugins.ts index 28766bb..c83f228 100644 --- a/apps/web/src/plugins/use-pond-plugins.ts +++ b/apps/web/src/plugins/use-pond-plugins.ts @@ -39,7 +39,9 @@ export function sectionStyleOptions(plugins: PluginView[] | undefined): SectionS } /** The block types the active `code` plugins offer for insertion (issue #76), - * flattened for the editor's insert menu. */ + * flattened for the editor's insert menu. `pageTool` surfaces are insertable + * too (issue #77: "embeddable as a plugin_block") — the sandbox drives both + * through the same render lifecycle. */ export interface PluginBlockOption { pluginId: string; blockType: string; @@ -51,7 +53,7 @@ export function pluginBlockOptions(plugins: PluginView[] | undefined): PluginBlo .filter((plugin) => plugin.kind === 'code') .flatMap((plugin) => plugin.extensionPoints - .filter((point) => point.type === 'block') + .filter((point) => point.type === 'block' || point.type === 'pageTool') .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 5b1d48b..9dc54ad 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -610,6 +610,34 @@ button { outline-color: var(--color-border); } +/* The page-tools panel (issue #77): active pageTool plugin surfaces behind + * disclosures; each iframe mounts lazily on first open. */ +.page-tools { + margin: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius); +} + +.page-tools__tool + .page-tools__tool { + border-top: 1px solid var(--color-border); +} + +.page-tools__toggle { + display: block; + width: 100%; + padding: var(--space-2) var(--space-3); + border: none; + background: var(--color-bg-subtle); + color: var(--color-text); + text-align: left; + font-size: 0.9rem; + cursor: pointer; +} + +.page-tools__tool .plugin-frame-host { + padding: var(--space-2) var(--space-3); +} + /* 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. */ diff --git a/docs/architecture/plugin-architecture.md b/docs/architecture/plugin-architecture.md index 9345881..1156f49 100644 --- a/docs/architecture/plugin-architecture.md +++ b/docs/architecture/plugin-architecture.md @@ -91,7 +91,7 @@ person looking at it could. | `readPond` | `listPages()`, `getPageOutline(pageId)`, `getPageContent(pageId)` | | `readBlock` | `getBlock(pageId, blockId)` — cross-page block embedding | | `blockData` | `getData()` / `setData(data)` for the plugin's own block instance (writes go through the editor as a normal document change — requires the viewer to have write permission) | -| `ui` | `resize(height)`, `openPage(pageId)` (host navigates), `toast(msgKey)` | +| `ui` | `resize(height)`, `openPage(pageId)` (host navigates), `toast(msgKey)`, `scrollToHeading(headingId)` (host scrolls to an outline entry, #77) | ## Lifecycle & administration diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index a365b2f..fcf6c00 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -30,7 +30,7 @@ export const CAPABILITY_METHODS = { readPond: ['listPages', 'getPageOutline', 'getPageContent'], readBlock: ['getBlock'], blockData: ['getData', 'setData'], - ui: ['resize', 'openPage', 'toast'], + ui: ['resize', 'openPage', 'toast', 'scrollToHeading'], } as const satisfies Record; /** Every host method name across all capabilities. */ diff --git a/packages/plugin-sdk/src/plugin.ts b/packages/plugin-sdk/src/plugin.ts index 32b8f1d..733c6d5 100644 --- a/packages/plugin-sdk/src/plugin.ts +++ b/packages/plugin-sdk/src/plugin.ts @@ -25,6 +25,9 @@ export interface PageSummary { id: string; title: string; slug: string; + /** Names of the page's labels — the page-index reference plugin filters on + * these (issue #77). Only pages the viewer may read arrive here at all. */ + labels: string[]; } /** @@ -55,6 +58,8 @@ export interface PluginHost { resize: (height: number) => Promise; openPage: (pageId: string) => Promise; toast: (messageKey: string) => Promise; + /** Scroll the host page to a heading by its outline id (issue #77). */ + scrollToHeading: (headingId: string) => Promise; }; } diff --git a/packages/plugins/page-index/build.mjs b/packages/plugins/page-index/build.mjs new file mode 100644 index 0000000..113a4a0 --- /dev/null +++ b/packages/plugins/page-index/build.mjs @@ -0,0 +1,34 @@ +// Builds the installable plugin (plugin-architecture.md §Package format): +// bundles src/plugin.ts (SDK + i18n inlined — the sandbox CSP forbids runtime +// fetches) into plugin.js as a single ES module, then packs the ZIP for the +// admin upload / dropzone watcher. +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { build } from 'esbuild'; +import { zipSync } from 'fflate'; + +const root = dirname(fileURLToPath(import.meta.url)); +const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); + +mkdirSync(join(root, 'dist'), { recursive: true }); +await build({ + entryPoints: [join(root, 'src/plugin.ts')], + bundle: true, + format: 'esm', + outfile: join(root, 'dist/plugin.js'), + minify: true, +}); + +const files = { + 'manifest.json': readFileSync(join(root, 'manifest.json')), + 'plugin.js': readFileSync(join(root, 'dist/plugin.js')), +}; +for (const name of readdirSync(join(root, 'i18n'))) { + files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name)); +} + +const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`); +writeFileSync(target, zipSync(files)); +console.log(`wrote ${target}`); diff --git a/packages/plugins/page-index/i18n/de.json b/packages/plugins/page-index/i18n/de.json new file mode 100644 index 0000000..ef721fc --- /dev/null +++ b/packages/plugins/page-index/i18n/de.json @@ -0,0 +1,5 @@ +{ + "empty": "Keine Seiten gefunden.", + "all": "Alle", + "error": "Der Seitenindex konnte nicht geladen werden." +} diff --git a/packages/plugins/page-index/i18n/en.json b/packages/plugins/page-index/i18n/en.json new file mode 100644 index 0000000..9bca1f4 --- /dev/null +++ b/packages/plugins/page-index/i18n/en.json @@ -0,0 +1,5 @@ +{ + "empty": "No pages found.", + "all": "All", + "error": "The page index could not be loaded." +} diff --git a/packages/plugins/page-index/manifest.json b/packages/plugins/page-index/manifest.json new file mode 100644 index 0000000..6d3660e --- /dev/null +++ b/packages/plugins/page-index/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "page-index", + "name": "Page Index", + "version": "1.0.0", + "apiVersion": "1", + "kind": "code", + "extensionPoints": [ + { + "type": "pageTool", + "id": "page-index", + "title": { "de": "Seitenindex", "en": "Page index" } + } + ], + "permissions": ["readPond", "ui"], + "fallback": { "type": "text", "value": "[Page index]" }, + "license": "MIT", + "i18n": { "de": "i18n/de.json", "en": "i18n/en.json" } +} diff --git a/packages/plugins/page-index/package.json b/packages/plugins/page-index/package.json new file mode 100644 index 0000000..36977fb --- /dev/null +++ b/packages/plugins/page-index/package.json @@ -0,0 +1,20 @@ +{ + "name": "@dorfteich/plugin-page-index", + "version": "0.0.0", + "private": true, + "description": "Reference pageTool plugin: label-filtered page list of the pond (issue #77)", + "license": "MIT", + "scripts": { + "build": "node build.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "devDependencies": { + "@dorfteich/plugin-sdk": "workspace:*", + "@types/node": "^26.1.0", + "esbuild": "^0.24.0", + "fflate": "^0.8.2", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/page-index/src/plugin.ts b/packages/plugins/page-index/src/plugin.ts new file mode 100644 index 0000000..cb01d96 --- /dev/null +++ b/packages/plugins/page-index/src/plugin.ts @@ -0,0 +1,104 @@ +import { createPlugin, windowTransport, type PageSummary } from '@dorfteich/plugin-sdk'; + +import de from '../i18n/de.json'; +import en from '../i18n/en.json'; + +/** + * Page-index reference plugin (issue #77, plugin-architecture.md §Reference + * plugins): lists the pond's pages the viewer may read (`readPond` — the + * server filters per page, so permissions are inherently respected), with + * label chips to filter by. Clicking a page navigates via `ui.openPage`. + * + * Strings are bundled (the sandbox CSP blocks runtime fetches); the files in + * `i18n/` are the single source and are inlined at build time. + */ +const STRINGS: Record> = { de, en }; + +function labelFor(locale: string, key: string): string { + const base = locale.split('-')[0] ?? locale; + return STRINGS[base]?.[key] ?? STRINGS.en?.[key] ?? key; +} + +const { host } = createPlugin({ + transport: windowTransport({ + // Window.postMessage's overloads don't structurally match the transport's + // minimal shape; this adapter pins the sandbox-safe wildcard origin. + target: { postMessage: (message) => window.parent.postMessage(message, '*') }, + source: window, + }), + onRender: (context) => void render(context.locale), +}); + +/** The active label filter; `null` shows every readable page. */ +let activeLabel: string | null = null; + +async function render(locale: string): Promise { + let pages: PageSummary[]; + try { + pages = await host.readPond.listPages(); + } catch { + document.body.textContent = labelFor(locale, 'error'); + return; + } + + document.body.textContent = ''; + document.body.className = 'dt-page-index'; + + const labels = [...new Set(pages.flatMap((page) => page.labels))].sort(); + if (labels.length > 0) { + document.body.appendChild(buildFilterBar(labels, locale)); + } + + const shown = activeLabel ? pages.filter((page) => page.labels.includes(activeLabel!)) : pages; + if (shown.length === 0) { + const empty = document.createElement('p'); + empty.textContent = labelFor(locale, 'empty'); + document.body.appendChild(empty); + } else { + const list = document.createElement('ul'); + for (const page of shown) { + const item = document.createElement('li'); + const link = document.createElement('a'); + link.href = '#'; + link.textContent = page.title; + link.dataset.pageId = page.id; + link.addEventListener('click', (event) => { + event.preventDefault(); + void host.ui.openPage(page.id); + }); + item.appendChild(link); + if (page.labels.length > 0) { + const chips = document.createElement('span'); + chips.textContent = ` (${page.labels.join(', ')})`; + item.appendChild(chips); + } + list.appendChild(item); + } + document.body.appendChild(list); + } + + void host.ui.resize(document.body.scrollHeight + 16); +} + +function buildFilterBar(labels: string[], locale: string): HTMLElement { + const bar = document.createElement('p'); + bar.className = 'dt-page-index__filters'; + const options: Array<{ value: string | null; text: string }> = [ + { value: null, text: labelFor(locale, 'all') }, + ...labels.map((label) => ({ value: label, text: label })), + ]; + for (const option of options) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = option.text; + button.dataset.filter = option.value ?? ''; + button.style.fontWeight = option.value === activeLabel ? 'bold' : 'normal'; + button.addEventListener('click', () => { + activeLabel = option.value; + void render(locale); + }); + bar.appendChild(button); + bar.appendChild(document.createTextNode(' ')); + } + return bar; +} diff --git a/packages/plugins/page-index/tsconfig.json b/packages/plugins/page-index/tsconfig.json new file mode 100644 index 0000000..1dbe32b --- /dev/null +++ b/packages/plugins/page-index/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "noEmit": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src", "*.ts"] +} diff --git a/packages/plugins/toc/build.mjs b/packages/plugins/toc/build.mjs new file mode 100644 index 0000000..113a4a0 --- /dev/null +++ b/packages/plugins/toc/build.mjs @@ -0,0 +1,34 @@ +// Builds the installable plugin (plugin-architecture.md §Package format): +// bundles src/plugin.ts (SDK + i18n inlined — the sandbox CSP forbids runtime +// fetches) into plugin.js as a single ES module, then packs the ZIP for the +// admin upload / dropzone watcher. +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { build } from 'esbuild'; +import { zipSync } from 'fflate'; + +const root = dirname(fileURLToPath(import.meta.url)); +const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); + +mkdirSync(join(root, 'dist'), { recursive: true }); +await build({ + entryPoints: [join(root, 'src/plugin.ts')], + bundle: true, + format: 'esm', + outfile: join(root, 'dist/plugin.js'), + minify: true, +}); + +const files = { + 'manifest.json': readFileSync(join(root, 'manifest.json')), + 'plugin.js': readFileSync(join(root, 'dist/plugin.js')), +}; +for (const name of readdirSync(join(root, 'i18n'))) { + files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name)); +} + +const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`); +writeFileSync(target, zipSync(files)); +console.log(`wrote ${target}`); diff --git a/packages/plugins/toc/i18n/de.json b/packages/plugins/toc/i18n/de.json new file mode 100644 index 0000000..1bc38d1 --- /dev/null +++ b/packages/plugins/toc/i18n/de.json @@ -0,0 +1,4 @@ +{ + "empty": "Diese Seite hat noch keine Überschriften.", + "error": "Das Inhaltsverzeichnis konnte nicht geladen werden." +} diff --git a/packages/plugins/toc/i18n/en.json b/packages/plugins/toc/i18n/en.json new file mode 100644 index 0000000..5dc608f --- /dev/null +++ b/packages/plugins/toc/i18n/en.json @@ -0,0 +1,4 @@ +{ + "empty": "This page has no headings yet.", + "error": "The table of contents could not be loaded." +} diff --git a/packages/plugins/toc/manifest.json b/packages/plugins/toc/manifest.json new file mode 100644 index 0000000..57cc1e9 --- /dev/null +++ b/packages/plugins/toc/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "toc", + "name": "Table of Contents", + "version": "1.0.0", + "apiVersion": "1", + "kind": "code", + "extensionPoints": [ + { + "type": "pageTool", + "id": "toc", + "title": { "de": "Inhaltsverzeichnis", "en": "Table of contents" } + } + ], + "permissions": ["readCurrentPage", "ui"], + "fallback": { "type": "text", "value": "[Table of contents]" }, + "license": "MIT", + "i18n": { "de": "i18n/de.json", "en": "i18n/en.json" } +} diff --git a/packages/plugins/toc/package.json b/packages/plugins/toc/package.json new file mode 100644 index 0000000..a79d165 --- /dev/null +++ b/packages/plugins/toc/package.json @@ -0,0 +1,20 @@ +{ + "name": "@dorfteich/plugin-toc", + "version": "0.0.0", + "private": true, + "description": "Reference pageTool plugin: table of contents from the current page's outline (issue #77)", + "license": "MIT", + "scripts": { + "build": "node build.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "devDependencies": { + "@dorfteich/plugin-sdk": "workspace:*", + "@types/node": "^26.1.0", + "esbuild": "^0.24.0", + "fflate": "^0.8.2", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/toc/src/plugin.ts b/packages/plugins/toc/src/plugin.ts new file mode 100644 index 0000000..a740b34 --- /dev/null +++ b/packages/plugins/toc/src/plugin.ts @@ -0,0 +1,92 @@ +import { createPlugin, windowTransport, type OutlineEntry } from '@dorfteich/plugin-sdk'; + +import de from '../i18n/de.json'; +import en from '../i18n/en.json'; + +/** + * Table-of-contents reference plugin (issue #77, plugin-architecture.md + * §Reference plugins): renders the current page's heading outline via the + * `readCurrentPage` capability; clicking an entry scrolls the host page to + * the heading (`ui.scrollToHeading`). The outline is re-fetched on a slow + * poll while the surface is mounted, so live heading edits appear once the + * server has re-derived the content cache (persistence debounce). + * + * Strings are bundled (the sandbox CSP blocks runtime fetches); the files in + * `i18n/` are the single source and are inlined at build time. + */ +const STRINGS: Record> = { de, en }; +const REFRESH_MS = 5000; + +function labelFor(locale: string, key: string): string { + const base = locale.split('-')[0] ?? locale; + return STRINGS[base]?.[key] ?? STRINGS.en?.[key] ?? key; +} + +const { host } = createPlugin({ + transport: windowTransport({ + // Window.postMessage's overloads don't structurally match the transport's + // minimal shape; this adapter pins the sandbox-safe wildcard origin. + target: { postMessage: (message) => window.parent.postMessage(message, '*') }, + source: window, + }), + onRender: (context) => start(context.locale), + onDestroy: () => stop(), +}); + +let timer: number | null = null; +let lastOutlineJson = ''; + +function start(locale: string): void { + stop(); + void refresh(locale, true); + timer = window.setInterval(() => void refresh(locale, false), REFRESH_MS); +} + +function stop(): void { + if (timer !== null) window.clearInterval(timer); + timer = null; +} + +async function refresh(locale: string, force: boolean): Promise { + let outline: OutlineEntry[]; + try { + outline = await host.readCurrentPage.getOutline(); + } catch { + document.body.textContent = labelFor(locale, 'error'); + return; + } + const json = JSON.stringify(outline); + if (!force && json === lastOutlineJson) return; + lastOutlineJson = json; + render(outline, locale); +} + +function render(outline: OutlineEntry[], locale: string): void { + document.body.textContent = ''; + document.body.className = 'dt-toc'; + + if (outline.length === 0) { + const empty = document.createElement('p'); + empty.textContent = labelFor(locale, 'empty'); + document.body.appendChild(empty); + } else { + const list = document.createElement('ul'); + for (const entry of outline) { + const item = document.createElement('li'); + item.style.marginLeft = `${(entry.level - 1) * 1}rem`; + const link = document.createElement('a'); + link.href = '#'; + link.textContent = entry.text; + link.dataset.headingId = entry.id; + link.addEventListener('click', (event) => { + event.preventDefault(); + void host.ui.scrollToHeading(entry.id); + }); + item.appendChild(link); + list.appendChild(item); + } + document.body.appendChild(list); + } + + void host.ui.resize(document.body.scrollHeight + 16); +} diff --git a/packages/plugins/toc/tsconfig.json b/packages/plugins/toc/tsconfig.json new file mode 100644 index 0000000..1dbe32b --- /dev/null +++ b/packages/plugins/toc/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "noEmit": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src", "*.ts"] +} diff --git a/packages/shared/i18n/de/plugins.json b/packages/shared/i18n/de/plugins.json index aa895a1..91b5370 100644 --- a/packages/shared/i18n/de/plugins.json +++ b/packages/shared/i18n/de/plugins.json @@ -8,6 +8,9 @@ "done": "Fertig", "inactive": "Das Plugin „{{name}}“ ist für diesen Teich nicht aktiv." }, + "tools": { + "title": "Seiten-Werkzeuge" + }, "preview": { "loading": "Plugins werden geladen …", "loadFailed": "Die Plugin-Liste konnte nicht geladen werden.", diff --git a/packages/shared/i18n/en/plugins.json b/packages/shared/i18n/en/plugins.json index 0d6b47e..074ec75 100644 --- a/packages/shared/i18n/en/plugins.json +++ b/packages/shared/i18n/en/plugins.json @@ -8,6 +8,9 @@ "done": "Done", "inactive": "The plugin “{{name}}” is not active for this pond." }, + "tools": { + "title": "Page tools" + }, "preview": { "loading": "Loading plugins …", "loadFailed": "The plugin list could not be loaded.", diff --git a/packages/shared/src/plugins.ts b/packages/shared/src/plugins.ts index 2e0ca19..b124ab7 100644 --- a/packages/shared/src/plugins.ts +++ b/packages/shared/src/plugins.ts @@ -108,6 +108,8 @@ export interface PluginPageSummary { id: string; title: string; slug: string; + /** Label names, for pageTool filtering (issue #77). */ + labels: string[]; } export interface PluginPageMeta { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ade9ccb..73d432f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -337,6 +337,27 @@ importers: specifier: ^3.0.0 version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) + packages/plugins/page-index: + devDependencies: + '@dorfteich/plugin-sdk': + specifier: workspace:* + version: link:../../plugin-sdk + '@types/node': + specifier: ^26.1.0 + version: 26.1.0 + esbuild: + specifier: ^0.24.0 + version: 0.24.2 + 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/plugins/section-styles-basic: devDependencies: '@dorfteich/plugin-sdk': @@ -355,6 +376,27 @@ importers: specifier: ^3.0.0 version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) + packages/plugins/toc: + devDependencies: + '@dorfteich/plugin-sdk': + specifier: workspace:* + version: link:../../plugin-sdk + '@types/node': + specifier: ^26.1.0 + version: 26.1.0 + esbuild: + specifier: ^0.24.0 + version: 0.24.2 + fflate: + specifier: ^0.8.2 + version: 0.8.3 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) + packages/shared: dependencies: markdown-it: @@ -979,6 +1021,12 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.24.2': + resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -997,6 +1045,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.24.2': + resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -1015,6 +1069,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.24.2': + resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -1033,6 +1093,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.24.2': + resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -1051,6 +1117,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.24.2': + resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -1069,6 +1141,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.24.2': + resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -1087,6 +1165,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.24.2': + resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -1105,6 +1189,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.24.2': + resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -1123,6 +1213,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.24.2': + resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -1141,6 +1237,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.24.2': + resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -1159,6 +1261,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.24.2': + resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -1177,6 +1285,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.24.2': + resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -1195,6 +1309,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.24.2': + resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -1213,6 +1333,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.24.2': + resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -1231,6 +1357,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.24.2': + resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -1249,6 +1381,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.24.2': + resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -1267,6 +1405,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.24.2': + resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -1285,6 +1429,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.24.2': + resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -1303,6 +1453,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.24.2': + resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -1321,6 +1477,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.24.2': + resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -1339,6 +1501,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.24.2': + resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -1375,6 +1543,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.24.2': + resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -1393,6 +1567,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.24.2': + resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -1411,6 +1591,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.24.2': + resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -1429,6 +1615,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.24.2': + resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -3212,6 +3404,11 @@ packages: resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} + esbuild@0.24.2: + resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -6338,6 +6535,9 @@ snapshots: '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.24.2': + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -6347,6 +6547,9 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.24.2': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true @@ -6356,6 +6559,9 @@ snapshots: '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.24.2': + optional: true + '@esbuild/android-arm@0.25.12': optional: true @@ -6365,6 +6571,9 @@ snapshots: '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.24.2': + optional: true + '@esbuild/android-x64@0.25.12': optional: true @@ -6374,6 +6583,9 @@ snapshots: '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.24.2': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true @@ -6383,6 +6595,9 @@ snapshots: '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.24.2': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true @@ -6392,6 +6607,9 @@ snapshots: '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.24.2': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true @@ -6401,6 +6619,9 @@ snapshots: '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.24.2': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true @@ -6410,6 +6631,9 @@ snapshots: '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.24.2': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true @@ -6419,6 +6643,9 @@ snapshots: '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.24.2': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true @@ -6428,6 +6655,9 @@ snapshots: '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.24.2': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true @@ -6437,6 +6667,9 @@ snapshots: '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.24.2': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true @@ -6446,6 +6679,9 @@ snapshots: '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.24.2': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true @@ -6455,6 +6691,9 @@ snapshots: '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.24.2': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true @@ -6464,6 +6703,9 @@ snapshots: '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.24.2': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true @@ -6473,6 +6715,9 @@ snapshots: '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.24.2': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true @@ -6482,6 +6727,9 @@ snapshots: '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.24.2': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true @@ -6491,6 +6739,9 @@ snapshots: '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.24.2': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true @@ -6500,6 +6751,9 @@ snapshots: '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.24.2': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true @@ -6509,6 +6763,9 @@ snapshots: '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.24.2': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true @@ -6518,6 +6775,9 @@ snapshots: '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.24.2': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true @@ -6536,6 +6796,9 @@ snapshots: '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.24.2': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true @@ -6545,6 +6808,9 @@ snapshots: '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.24.2': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true @@ -6554,6 +6820,9 @@ snapshots: '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.24.2': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true @@ -6563,6 +6832,9 @@ snapshots: '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.24.2': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -8401,6 +8673,34 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.24.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.2 + '@esbuild/android-arm': 0.24.2 + '@esbuild/android-arm64': 0.24.2 + '@esbuild/android-x64': 0.24.2 + '@esbuild/darwin-arm64': 0.24.2 + '@esbuild/darwin-x64': 0.24.2 + '@esbuild/freebsd-arm64': 0.24.2 + '@esbuild/freebsd-x64': 0.24.2 + '@esbuild/linux-arm': 0.24.2 + '@esbuild/linux-arm64': 0.24.2 + '@esbuild/linux-ia32': 0.24.2 + '@esbuild/linux-loong64': 0.24.2 + '@esbuild/linux-mips64el': 0.24.2 + '@esbuild/linux-ppc64': 0.24.2 + '@esbuild/linux-riscv64': 0.24.2 + '@esbuild/linux-s390x': 0.24.2 + '@esbuild/linux-x64': 0.24.2 + '@esbuild/netbsd-arm64': 0.24.2 + '@esbuild/netbsd-x64': 0.24.2 + '@esbuild/openbsd-arm64': 0.24.2 + '@esbuild/openbsd-x64': 0.24.2 + '@esbuild/sunos-x64': 0.24.2 + '@esbuild/win32-arm64': 0.24.2 + '@esbuild/win32-ia32': 0.24.2 + '@esbuild/win32-x64': 0.24.2 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12