From 0003063c3947325fc6cc7ce67193d076175ea8c2 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 11 Jul 2026 13:27:30 +0200 Subject: [PATCH] Add pageTool plugins with toc and page-index references (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read-only widget surface over page/pond data (ADR 0008 extension point `pageTool`): - Host: PageToolsPanel lists the pond's active pageTool surfaces behind disclosures — each sandbox iframe mounts lazily on first open and tears down on close. The same surfaces are insertable as plugin_block embeds (#76's insert picker now offers pageTool points too; the sandbox drives both through the same render lifecycle). - New `ui.scrollToHeading(headingId)` capability: outline ids are derived from the doc and never stamped into the DOM, so the host resolves the id to its heading position via the shared extractOutline and scrolls the matching rendered heading. - `readPond.listPages` now carries label *names* per summary (PagesService.pluginPageSummaries) — the page-index filter chips work on data the viewer could resolve anyway; per-page permission filtering stays in the service as before. - Reference plugins packages/plugins/toc and packages/plugins/page-index: real SDK consumers (createPlugin + windowTransport), bundled with esbuild into the package ZIP; i18n de/en is inlined at build time — the sandbox CSP forbids runtime fetches, the i18n/ files stay the single source. The toc re-fetches its outline on a slow poll, so live heading edits appear once the collab server has re-derived the content cache. - e2e page-tools.spec.ts covers the acceptance criteria: live outline updates after the persistence debounce, heading click scrolls, embedded page-index navigates via ui.openPage, and a label-restricted reader never sees the denied page in the index. - CI: the auth-e2e job now runs the section-styles (missed in #75), plugin-blocks, and page-tools packs, with login-rate-limit resets. - plugins.e2e.db.test clears the plugin registry up front: a local dev DB is shared with the e2e stack, whose installed real `toc` would otherwise collide with the fixture of the same id. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .gitea/workflows/ci.yml | 27 ++ apps/api/src/pages/pages.service.ts | 21 ++ apps/api/src/pages/plugin-api.controller.ts | 8 +- apps/api/src/pages/plugin-api.e2e.db.test.ts | 4 + apps/api/src/plugins/plugins.e2e.db.test.ts | 5 + apps/web/e2e/page-tools.spec.ts | 233 ++++++++++++++ apps/web/src/editor/plugin-block-context.tsx | 2 + apps/web/src/pages/PageEditorPage.tsx | 29 +- apps/web/src/plugins/PageToolsPanel.tsx | 81 +++++ apps/web/src/plugins/host-capabilities.ts | 6 + apps/web/src/plugins/use-pond-plugins.ts | 6 +- apps/web/src/styles/base.css | 28 ++ docs/architecture/plugin-architecture.md | 2 +- packages/plugin-sdk/src/capabilities.ts | 2 +- packages/plugin-sdk/src/plugin.ts | 5 + packages/plugins/page-index/build.mjs | 34 +++ packages/plugins/page-index/i18n/de.json | 5 + packages/plugins/page-index/i18n/en.json | 5 + packages/plugins/page-index/manifest.json | 18 ++ packages/plugins/page-index/package.json | 20 ++ packages/plugins/page-index/src/plugin.ts | 104 +++++++ packages/plugins/page-index/tsconfig.json | 11 + packages/plugins/toc/build.mjs | 34 +++ packages/plugins/toc/i18n/de.json | 4 + packages/plugins/toc/i18n/en.json | 4 + packages/plugins/toc/manifest.json | 18 ++ packages/plugins/toc/package.json | 20 ++ packages/plugins/toc/src/plugin.ts | 92 ++++++ packages/plugins/toc/tsconfig.json | 11 + packages/shared/i18n/de/plugins.json | 3 + packages/shared/i18n/en/plugins.json | 3 + packages/shared/src/plugins.ts | 2 + pnpm-lock.yaml | 300 +++++++++++++++++++ 33 files changed, 1137 insertions(+), 10 deletions(-) create mode 100644 apps/web/e2e/page-tools.spec.ts create mode 100644 apps/web/src/plugins/PageToolsPanel.tsx create mode 100644 packages/plugins/page-index/build.mjs create mode 100644 packages/plugins/page-index/i18n/de.json create mode 100644 packages/plugins/page-index/i18n/en.json create mode 100644 packages/plugins/page-index/manifest.json create mode 100644 packages/plugins/page-index/package.json create mode 100644 packages/plugins/page-index/src/plugin.ts create mode 100644 packages/plugins/page-index/tsconfig.json create mode 100644 packages/plugins/toc/build.mjs create mode 100644 packages/plugins/toc/i18n/de.json create mode 100644 packages/plugins/toc/i18n/en.json create mode 100644 packages/plugins/toc/manifest.json create mode 100644 packages/plugins/toc/package.json create mode 100644 packages/plugins/toc/src/plugin.ts create mode 100644 packages/plugins/toc/tsconfig.json 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