From e1dbf0e6cd5460e22a083542b3090994356f2691 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 19 Jul 2026 00:46:00 +0200 Subject: [PATCH 01/10] graphify: CLAUDE.md-Sektion + PreToolUse-Hooks, graphify-out/ gitignored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `graphify claude install` im Monorepo: graphify-Abschnitt an (neue) CLAUDE.md angehängt und PreToolUse-Hooks in .claude/settings.json registriert (Graph-Check vor Such-/Lesetools, Auto-Rebuild nach Code-Änderungen). Der generierte graphify-out/ (AST-Graph über 741 Code-Dateien, ~10 MB) wird nicht versioniert. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- .claude/settings.json | 24 ++++++++++++++++++++++++ .gitignore | 3 +++ CLAUDE.md | 9 +++++++++ 3 files changed, 36 insertions(+) create mode 100644 .claude/settings.json create mode 100644 CLAUDE.md diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e6f54a3 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,24 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Grep", + "hooks": [ + { + "type": "command", + "command": "/Users/stwaidele/.local/bin/graphify hook-guard search" + } + ] + }, + { + "matcher": "Read|Glob", + "hooks": [ + { + "type": "command", + "command": "/Users/stwaidele/.local/bin/graphify hook-guard read" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index cd36888..ff5c054 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ apps/api/data/ # Font catalog WOFF2 + generated stylesheet — fetched at build time # (ADR 0016, deploy/fonts/build-fonts.mjs), never committed. apps/web/public/fonts/ + +# graphify knowledge graph (generated) +graphify-out/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..417efeb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +## graphify + +This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. + +Rules: +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. +- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). -- 2.45.2 From d1d98a95753918e8ce83463be08fe0c6f582f947 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 19 Jul 2026 00:55:18 +0200 Subject: [PATCH 02/10] =?UTF-8?q?#137=20Checkbox-Aufz=C3=A4hlungen:=20Text?= =?UTF-8?q?zeile=20vertikal=20ausrichten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Task-List-CSS war auf `.editor-content` gescoped und griff daher in keinem Lese-Container (public/comment/legal/home/history-preview), wo docToHtml sein `
  • `-Markup einspeist — dort blieb der Bullet sichtbar, die Checkbox lag inline und das Block-`

    ` mit Default- `margin: 1em 0` versetzte den Text in die nächste Zeile. Fix: Task-List-Regeln über das eindeutige `data-type='task_list'`-Attribut (nur von docToHtml und der Editor-NodeView erzeugt) entscopen, sodass sie im Editor UND in allen Lese-Containern greifen; Checkbox per kleinem margin-top auf die erste Textzeile ausrichten und Ober-/Untermarge des Item-Absatzes neutralisieren. Deckt beide DOM-Formen ab: Lesemodus `li > input` + `li > p`, Editor `li > label > input` + `li > div > p`. Visuell verifiziert (Vorher/Nachher, beide Pfade). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/web/src/styles/base.css | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 4f42ef9..c10aac2 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1581,17 +1581,44 @@ button { padding: 0; } -.editor-content ul[data-type='task_list'] { +/* Task lists (checkbox lists). Styled globally via the unique + `data-type='task_list'` attribute — produced only by docToHtml and the + editor nodeview — so checkbox and text line up in the editor + (.editor-content) AND every read-mode container that injects docToHtml + output (.public-page__body, .comment__body, .legal-page__body, .home-page, + .history-panel__preview). Previously these rules were scoped to + .editor-content and never reached the read views. Issue #137. */ +ul[data-type='task_list'] { list-style: none; padding-left: var(--space-2); } -.editor-content ul[data-type='task_list'] li { +ul[data-type='task_list'] li { display: flex; align-items: flex-start; gap: var(--space-2); } +/* The checkbox is the top flex child; nudge it onto the first text line and + drop the leading/trailing margins of the item's paragraph so the text meets + the checkbox instead of dropping a line. Covers both DOM shapes: read-mode + `li > input` + `li > p`, and editor `li > label > input` + `li > div > p`. */ +ul[data-type='task_list'] li > input[type='checkbox'], +ul[data-type='task_list'] li > label { + flex: none; + margin-top: 0.25em; +} + +ul[data-type='task_list'] li > p:first-child, +ul[data-type='task_list'] li > div > p:first-child { + margin-top: 0; +} + +ul[data-type='task_list'] li > p:last-child, +ul[data-type='task_list'] li > div > p:last-child { + margin-bottom: 0; +} + .editor-content table { border-collapse: collapse; margin: var(--space-4) 0; -- 2.45.2 From 2974a54ef87ddbf137f684c2b02cc5e79a85d7d9 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 19 Jul 2026 01:02:58 +0200 Subject: [PATCH 03/10] chore: graphify/agent-Config aus Prettier ausnehmen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `graphify claude install` erzeugte CLAUDE.md und .claude/settings.json, die nicht Prettier-konform sind und `pnpm lint`/CI rotmachen würden. Diese Dateien sind tool-generiert (bei Re-Install neu geschrieben), daher per .prettierignore ausgenommen statt von Hand formatiert. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- .prettierignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.prettierignore b/.prettierignore index ab6b0b5..da5247c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,3 +15,8 @@ fixtures/import/*.src.html # as pandoc reads the exported document back — Prettier would break them. fixtures/export/*.expected.md packages/plugins/*/vendor/ +# Agent/tool config generated by Claude Code + `graphify claude install` +# (regenerated on demand, not hand-formatted source) — keep out of Prettier so +# a re-install never breaks the lint gate. +CLAUDE.md +.claude/ -- 2.45.2 From c858f12592377a2488d7d8352ea216917daa4468 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 19 Jul 2026 01:04:11 +0200 Subject: [PATCH 04/10] #134 Statuszeile zwischen Navigation und Artikel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue schlanke Statuszeile (letzte Aktualisierung · Wortzahl · geschätzte Lesezeit) zwischen Seitenkopf und Artikel — im authentifizierten Lesemodus und in der öffentlichen Ansicht. - Geteilte Komponente `PageStatusBar` (Datum via Intl in der aktiven Sprache, Lesezeit = ceil(Wörter/200), Singular/Plural, Lesezeit ausgeblendet bei 0 Wörtern). - `countWords`/`htmlToText`-Helfer in lib/word-count.ts. - Authentifiziert (`PageEditorPage`, nur Lesemodus): Wortzahl aus dem vorhandenen Markdown-Export (geteilter Query-Key ['page-markdown']), `updatedAt` direkt von `page.data`. - Öffentlich (`PublicPageView`): Wortzahl aus dem server-gerenderten HTML per DOMParser — kein Editor-Bundle nötig; kein Backend-Change. - i18n common.statusbar (de+en), CSS `.page-statusbar` (middot-getrennt, gedämpft). Gates grün (typecheck/lint/i18n:check); visuell verifiziert. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/web/src/lib/word-count.ts | 22 ++++++++++++++ apps/web/src/pages/PageEditorPage.tsx | 19 +++++++++++- apps/web/src/pages/PageStatusBar.tsx | 44 +++++++++++++++++++++++++++ apps/web/src/pages/PublicPageView.tsx | 12 ++++++++ apps/web/src/styles/base.css | 23 ++++++++++++++ packages/shared/i18n/de/common.json | 7 +++++ packages/shared/i18n/en/common.json | 7 +++++ 7 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/word-count.ts create mode 100644 apps/web/src/pages/PageStatusBar.tsx diff --git a/apps/web/src/lib/word-count.ts b/apps/web/src/lib/word-count.ts new file mode 100644 index 0000000..5eb2a30 --- /dev/null +++ b/apps/web/src/lib/word-count.ts @@ -0,0 +1,22 @@ +/** + * Approximate word count from plain text (issue #134). Splits on whitespace + * and keeps only tokens that contain a letter or digit, so markdown/HTML + * punctuation (`##`, `-`, `|`, `>`) never inflates the count. + */ +export function countWords(text: string): number { + if (!text) return 0; + return text + .trim() + .split(/\s+/) + .filter((token) => /[\p{L}\p{N}]/u.test(token)).length; +} + +/** + * Strip HTML to its visible text using the browser's parser (issue #134). Used + * by the public read view to derive a word count from the server-rendered HTML + * without loading the editor bundle. + */ +export function htmlToText(html: string): string { + if (!html) return ''; + return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? ''; +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 96d9d77..86918c3 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -32,9 +32,11 @@ import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete'; import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context'; import { usePageActionsSlot } from '../layout/page-actions'; import { useForceSidebarHidden } from '../layout/sidebar-chrome'; -import { ApiError, apiGet, apiPatch, apiPost } from '../lib/api'; +import { ApiError, apiGet, apiGetText, apiPatch, apiPost } from '../lib/api'; import { hasPrimaryModifier, isTypingTarget } from '../lib/keyboard'; +import { countWords } from '../lib/word-count'; import { PageActions } from './PageActions'; +import { PageStatusBar } from './PageStatusBar'; import { recallPage, rememberPage } from '../offline/page-cache'; import { PluginBlockContext } from '../editor/plugin-block-context'; import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel'; @@ -360,6 +362,16 @@ export function PageEditorPage(): React.JSX.Element { enabled: Boolean(pond.data), }); + // Word count for the read-mode status line (#134). Derived from the same + // Markdown export the history panel uses (shared query key), fetched only in + // reading mode so the editor session pays nothing. + const pageMarkdown = useQuery({ + queryKey: ['page-markdown', page.data?.id], + queryFn: () => apiGetText(`/pages/${page.data!.id}/export/markdown`), + enabled: mode === 'view' && Boolean(page.data?.id), + }); + const wordCount = useMemo(() => countWords(pageMarkdown.data ?? ''), [pageMarkdown.data]); + // Remember this page's metadata while online so it can be opened offline (#38). useEffect(() => { if (pond.data && page.data) { @@ -521,6 +533,11 @@ export function PageEditorPage(): React.JSX.Element { onBlur={() => void saveTitle()} /> + {/* Status line between the header and the article (#134): last update, + word count, reading time — reading mode only. */} + {mode === 'view' && page.data && ( + + )}

    { + const date = new Date(updatedAt); + if (Number.isNaN(date.getTime())) return null; + return new Intl.DateTimeFormat(i18n.language, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); + }, [updatedAt, i18n.language]); + + const minutes = Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE)); + + return ( +
    + {updated && ( + {t('statusbar.updated', { date: updated })} + )} + {t('statusbar.words', { count: wordCount })} + {wordCount > 0 && ( + {t('statusbar.readingTime', { minutes })} + )} +
    + ); +} diff --git a/apps/web/src/pages/PublicPageView.tsx b/apps/web/src/pages/PublicPageView.tsx index 23d9981..1db8ecd 100644 --- a/apps/web/src/pages/PublicPageView.tsx +++ b/apps/web/src/pages/PublicPageView.tsx @@ -1,9 +1,12 @@ import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { ApiError, apiGet } from '../lib/api'; +import { countWords, htmlToText } from '../lib/word-count'; import { NotFoundPage } from './NotFoundPage'; +import { PageStatusBar } from './PageStatusBar'; interface PublicPageContent { pondName: string; @@ -31,6 +34,14 @@ export function PublicPageView(): React.JSX.Element { retry: false, }); + // Word count for the status line (#134), derived from the server-rendered + // HTML — no editor bundle needed. Kept before the early returns so the hook + // order stays stable. + const wordCount = useMemo( + () => countWords(htmlToText(query.data?.html ?? '')), + [query.data?.html], + ); + if (query.error instanceof ApiError && query.error.status === 404) return ; if (query.isLoading || !query.data) return
    ; @@ -40,6 +51,7 @@ export function PublicPageView(): React.JSX.Element {

    {t('readOnlyBadge')}

    {page.pondName}

    {page.title}

    + {/* The HTML comes from the server's content cache (issue #24), derived from the sanitized editor schema — safe to render. */}
    diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index c10aac2..b09634f 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -979,6 +979,29 @@ button { outline-offset: 2px; } +/* Status line between page header and article (#134). Middot-separated, + muted; shared by the authenticated read view and the public view. */ +.page-statusbar { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: calc(-1 * var(--space-2)); + margin-bottom: var(--space-4); + color: var(--color-text-muted); + font-size: 0.8125rem; +} + +.page-statusbar__item { + display: inline-flex; + align-items: center; +} + +.page-statusbar__item:not(:first-child)::before { + content: '·'; + margin-right: var(--space-2); + color: var(--color-border); +} + /* The export entries flow inline with the other overflow-menu items. */ .editor-page__export { display: contents; diff --git a/packages/shared/i18n/de/common.json b/packages/shared/i18n/de/common.json index 10c9916..0117fa0 100644 --- a/packages/shared/i18n/de/common.json +++ b/packages/shared/i18n/de/common.json @@ -1,4 +1,11 @@ { + "statusbar": { + "label": "Seiteninformationen", + "updated": "Aktualisiert {{date}}", + "words_one": "{{count}} Wort", + "words_other": "{{count}} Wörter", + "readingTime": "ca. {{minutes}} Min. Lesezeit" + }, "layout": { "sidebar": { "expand": "Seitenleiste einblenden", diff --git a/packages/shared/i18n/en/common.json b/packages/shared/i18n/en/common.json index 919dcb8..b9e8576 100644 --- a/packages/shared/i18n/en/common.json +++ b/packages/shared/i18n/en/common.json @@ -1,4 +1,11 @@ { + "statusbar": { + "label": "Page information", + "updated": "Updated {{date}}", + "words_one": "{{count}} word", + "words_other": "{{count}} words", + "readingTime": "~{{minutes}} min read" + }, "layout": { "sidebar": { "expand": "Show sidebar", -- 2.45.2 From f014a61480c67841fd6d113f436b9f10f7a56195 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 19 Jul 2026 01:22:22 +0200 Subject: [PATCH 05/10] =?UTF-8?q?#133=20Kommentare=20fest=20inline=20im=20?= =?UTF-8?q?Lesemodus=20(Slide-in-Panel=20abl=C3=B6sen)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kommentare erscheinen jetzt fest im Lesefluss zwischen Backlinks und lokalem Graph statt in einem ein-/ausblendbaren Panel. Der Kopfleisten-Toggle (Icon + Unread-Badge) entfällt. Frontend: - CommentsPanel → CommentsSection (Inline-Sektion, ohne Panel-Chrome/ Close-Knopf; markiert beim Sichtbarwerden als gelesen). Neue Read-only-Variante PublicComments für die anonyme öffentliche Ansicht. - Umzug auf die äußere Ebene in PageEditorPage (view-Modus, zwischen BacklinksPanel und LocalGraphPanel). Das Schreibrecht (collab rw) wird per onWriteAccess aus dem inneren PageEditor hochgereicht, damit die äußere Ebene den Composer bei commentPolicy=editors korrekt zeigt/ verbirgt. - Deep-Link ?comments=1 scrollt jetzt zur Inline-Sektion statt ein Panel zu öffnen. Resolve/Unresolve-Knöpfe zusätzlich an mayComment gekoppelt (früher nur an isRoot) — Leser sehen keine 403-Knöpfe mehr; Read-only blendet alle Aktions-Controls aus. - CSS comments-panel* → comments-section*; tote Unread-Badge-Regeln raus. Backend: - GET /public/:pondSlug/:pageSlug/comments (@Public), read-only. Nutzt den vorhandenen resolve()-Pfad (erzwingt ggf. anonymen Lesezugriff → nicht öffentliche Seiten 404en) und CommentsService.list. PublicModule importiert CommentsModule. Tests: public.e2e.db.test.ts um anonymen Kommentar-Lesezugriff + 404-Fälle ergänzt (grün gegen frische Test-DB); comments.spec.ts auf die Inline-UI umgestellt. typecheck/lint/i18n:check grün. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/api/src/public/public.controller.ts | 11 + apps/api/src/public/public.e2e.db.test.ts | 26 ++- apps/api/src/public/public.module.ts | 3 +- apps/api/src/public/public.service.ts | 15 ++ apps/web/e2e/comments.spec.ts | 33 ++- ...{CommentsPanel.tsx => CommentsSection.tsx} | 221 ++++++++++++------ apps/web/src/pages/PageActions.tsx | 18 -- apps/web/src/pages/PageEditorPage.tsx | 67 +++--- apps/web/src/pages/PublicPageView.tsx | 3 + apps/web/src/styles/base.css | 40 +--- 10 files changed, 274 insertions(+), 163 deletions(-) rename apps/web/src/comments/{CommentsPanel.tsx => CommentsSection.tsx} (59%) diff --git a/apps/api/src/public/public.controller.ts b/apps/api/src/public/public.controller.ts index c95a0a5..f5b8507 100644 --- a/apps/api/src/public/public.controller.ts +++ b/apps/api/src/public/public.controller.ts @@ -1,4 +1,5 @@ import { Controller, Get, Param, Req, Res } from '@nestjs/common'; +import type { PageCommentsView } from '@dorfteich/shared'; import type { Response } from 'express'; import { AuthedRequest, Public } from '../auth/auth.guard'; @@ -24,6 +25,16 @@ export class PublicController { return this.publicPages.content(request.user ?? null, pondSlug, pageSlug); } + @Get(':pondSlug/:pageSlug/comments') + @Public() + async comments( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Req() request: AuthedRequest, + ): Promise { + return this.publicPages.comments(request.user ?? null, pondSlug, pageSlug); + } + @Get(':pondSlug/:pageSlug') @Public() async html( diff --git a/apps/api/src/public/public.e2e.db.test.ts b/apps/api/src/public/public.e2e.db.test.ts index 0e56319..ed41f26 100644 --- a/apps/api/src/public/public.e2e.db.test.ts +++ b/apps/api/src/public/public.e2e.db.test.ts @@ -115,12 +115,36 @@ describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => { await api().get(`/api/v1/public/${pondSlug}/does-not-exist`).expect(404); }); - it('404s both endpoints once the public grant is removed', async () => { + it('serves a page’s comments read-only to an anonymous visitor (issue #133)', async () => { + const page = await prisma.page.findFirstOrThrow({ where: { pondId, slug: pageSlug } }); + const root = await prisma.comment.create({ + data: { pageId: page.id, authorId: ownerId, body: 'A public remark' }, + }); + await prisma.comment.create({ + data: { pageId: page.id, parentId: root.id, authorId: ownerId, body: 'A public reply' }, + }); + + const res = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/comments`).expect(200); + const body = res.body as { + threads: { root: { body: string }; replies: { body: string }[] }[]; + openCount: number; + }; + expect(body.openCount).toBe(1); + expect(body.threads).toHaveLength(1); + expect(body.threads[0].root.body).toBe('A public remark'); + expect(body.threads[0].replies.map((r) => r.body)).toEqual(['A public reply']); + + // A non-public page never reveals its comments either. + await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/comments`).expect(404); + }); + + it('404s all public endpoints once the public grant is removed', async () => { await prisma.roleGrant.delete({ where: { id: publicGrantId } }); // The API route invalidates on its own mutations; this test deletes // directly, so drop the cached pond context to mirror that (issue #39). app.get(PondPermissionCache).invalidate(pondId); await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(404); await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(404); + await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/comments`).expect(404); }); }); diff --git a/apps/api/src/public/public.module.ts b/apps/api/src/public/public.module.ts index 2760e64..91c1604 100644 --- a/apps/api/src/public/public.module.ts +++ b/apps/api/src/public/public.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; +import { CommentsModule } from '../comments/comments.module'; import { PluginsModule } from '../plugins/plugins.module'; import { PublicController } from './public.controller'; @@ -12,7 +13,7 @@ import { PublicService } from './public.service'; * marks `GET /media/:fileId` public too. */ @Module({ - imports: [PluginsModule], + imports: [PluginsModule, CommentsModule], controllers: [PublicController], providers: [PublicService], }) diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 84e910b..ad6faa8 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -1,6 +1,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import type { PageCommentsView } from '@dorfteich/shared'; import { Pond, User } from '@prisma/client'; +import { CommentsService } from '../comments/comments.service'; import { PermissionService } from '../permissions/permission.service'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PrismaService } from '../prisma/prisma.service'; @@ -38,6 +40,7 @@ export class PublicService { private readonly permissions: PermissionService, private readonly fallbacks: PluginFallbackRenderer, private readonly settings: InstanceSettingsService, + private readonly commentsService: CommentsService, ) {} private async resolve( @@ -77,6 +80,18 @@ export class PublicService { }; } + /** + * The page's comments for the anonymous public view (issue #133), read-only. + * `resolve()` enforces (possibly anonymous) read access — a non-public page + * 404s here too, so comments never leak. `list` builds the same + * `PageCommentsView` the authenticated endpoint returns; the SPA renders it + * without any composer or action controls. + */ + async comments(user: User | null, pondSlug: string, pageSlug: string): Promise { + const { page } = await this.resolve(user, pondSlug, pageSlug); + return this.commentsService.list(page.id, 'all'); + } + /** A complete, self-contained HTML document for crawlers / PDF export. */ async html( user: User | null, diff --git a/apps/web/e2e/comments.spec.ts b/apps/web/e2e/comments.spec.ts index b71a9c1..382c498 100644 --- a/apps/web/e2e/comments.spec.ts +++ b/apps/web/e2e/comments.spec.ts @@ -5,10 +5,11 @@ import { contextForUser } from './helpers'; const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; /** - * Comments UI (issue #92): the full two-user lifecycle in the panel, - * resolve/unresolve with the collapsed section, the permission variant - * (composer hidden with a hint), and the localStorage unread badge. - * The pack provisions its own page and grant, so it is repeatable. + * Comments UI (issues #92, #133): the discussion is a fixed inline section in + * the read view (no toggle), between the backlinks and the local graph. The + * pack drives the full two-user lifecycle, resolve/unresolve with the collapsed + * section, and the permission variant (composer hidden with a hint). It + * provisions its own page and grant, so it is repeatable. */ let pondId: string; @@ -52,23 +53,21 @@ test.afterAll(async ({ browser }) => { }); test('two users run the full comment lifecycle with resolve/unresolve', async ({ browser }) => { - // The reader starts the thread. + // The reader starts the thread — the section is inline in the read view. const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); const readerPage = await reader.newPage(); await readerPage.goto(pageUrl); - await readerPage.locator('.editor-shell__comments-toggle').click(); - const readerPanel = readerPage.locator('.comments-panel'); + const readerPanel = readerPage.locator('.comments-section'); await readerPanel.locator('.comments-composer textarea').fill('Is this **final**?'); await readerPanel.locator('.comments-composer button[type="submit"]').click(); await expect(readerPanel.locator('.comment__body strong')).toHaveText('final'); - // The owner sees the unread badge, replies, and resolves the thread. + // The owner opens the page and sees the thread inline, replies, resolves. const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); const ownerPage = await owner.newPage(); await ownerPage.goto(pageUrl); - await expect(ownerPage.locator('.comments-unread-badge')).toBeVisible(); - await ownerPage.locator('.editor-shell__comments-toggle').click(); - const ownerPanel = ownerPage.locator('.comments-panel'); + const ownerPanel = ownerPage.locator('.comments-section'); + await expect(ownerPanel.locator('.comment__body strong')).toHaveText('final'); await ownerPanel .locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i }) .click(); @@ -83,7 +82,7 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({ .locator('.comment__actions .comments-link-button', { hasText: /resolve|erledig/i }) .first() .click(); - const resolvedSection = ownerPanel.locator('.comments-panel__resolved'); + const resolvedSection = ownerPanel.locator('.comments-section__resolved'); await expect(resolvedSection).toBeVisible(); await expect(resolvedSection.locator('details, summary').first()).toBeVisible(); // Collapsed: the thread body is hidden until the section is opened. @@ -93,7 +92,7 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({ // Unresolve restores it to the open list. await resolvedSection.locator('.comments-link-button', { hasText: /reopen|öffnen/i }).click(); - await expect(ownerPanel.locator('.comments-panel__resolved')).toHaveCount(0); + await expect(ownerPanel.locator('.comments-section__resolved')).toHaveCount(0); await expect(ownerPanel.locator('.comments-thread .comment__body strong')).toBeVisible(); // Author edits and deletes own reply. @@ -108,9 +107,8 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({ // Cleanup: the reader deletes their own (now reply-free) root. await readerPage.reload(); - await readerPage.locator('.editor-shell__comments-toggle').click(); await readerPanel.locator('.comments-link-button', { hasText: /delete|löschen/i }).click(); - await expect(readerPanel.locator('.comments-panel__empty')).toBeVisible(); + await expect(readerPanel.locator('.comments-section__empty')).toBeVisible(); await reader.close(); await owner.close(); @@ -127,9 +125,8 @@ test('the composer hides with a hint when the policy bars readers', async ({ bro const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); const page = await reader.newPage(); await page.goto(pageUrl); - await page.locator('.editor-shell__comments-toggle').click(); - const panel = page.locator('.comments-panel'); - await expect(panel.locator('.comments-panel__policy-hint')).toBeVisible(); + const panel = page.locator('.comments-section'); + await expect(panel.locator('.comments-section__policy-hint')).toBeVisible(); await expect(panel.locator('.comments-composer')).toHaveCount(0); await reader.close(); diff --git a/apps/web/src/comments/CommentsPanel.tsx b/apps/web/src/comments/CommentsSection.tsx similarity index 59% rename from apps/web/src/comments/CommentsPanel.tsx rename to apps/web/src/comments/CommentsSection.tsx index 12c1f9f..3b40953 100644 --- a/apps/web/src/comments/CommentsPanel.tsx +++ b/apps/web/src/comments/CommentsSection.tsx @@ -1,35 +1,36 @@ -import type { CommentThreadView, CommentView } from '@dorfteich/shared'; +import type { PageCommentsView, CommentThreadView, CommentView } from '@dorfteich/shared'; +import { useQuery } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../auth/auth-context'; import { FormError } from '../components/forms'; -import { apiDelete, apiPatch, apiPost } from '../lib/api'; +import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; import { markCommentsSeen, useComments, useInvalidateComments } from './use-comments'; /** - * The page's discussion panel (issue #92): threaded comments with a - * Markdown composer, edit/delete for authors, resolve with a collapsed - * resolved section, and the permission-aware composer (hidden with a hint - * when the pond's policy bars the viewer). + * The page's discussion (issue #92), rendered inline in the read view between + * the backlinks and the local graph (issue #133): threaded comments with a + * Markdown composer, edit/delete for authors, and resolve with a collapsed + * resolved section. The composer is hidden with a hint when the pond's policy + * bars the viewer. Replaces the former slide-in panel. */ -export function CommentsPanel({ +export function CommentsSection({ pageId, mayComment, - onClose, }: { pageId: string; - /** Resolved by the caller from the pond's commentPolicy + collab mode. */ + /** Resolved by the caller from the pond's commentPolicy + write access. */ mayComment: boolean; - onClose: () => void; }): React.JSX.Element { const { t } = useTranslation('comments'); const comments = useComments(pageId); const refresh = useInvalidateComments(pageId); const [error, setError] = useState(null); - // Opening the panel is "visiting" the discussion: the unread badge resets. + // Seeing the inline section is "visiting" the discussion: the unread notion + // (used by comment notifications) resets while it is on screen. useEffect(() => { markCommentsSeen(pageId); return () => markCommentsSeen(pageId); @@ -45,22 +46,15 @@ export function CommentsPanel({ } }; - const view = comments.data; - const open = (view?.threads ?? []).filter((thread) => !thread.resolved); - const resolved = (view?.threads ?? []).filter((thread) => thread.resolved); - return ( -
    -
    +
    +

    {t('title')} - {view && view.openCount > 0 && ( - {view.openCount} + {comments.data && comments.data.openCount > 0 && ( + {comments.data.openCount} )}

    -
    @@ -71,14 +65,82 @@ export function CommentsPanel({ onSubmit={(body) => run(() => apiPost(`/pages/${pageId}/comments`, { body }))} /> ) : ( -

    {t('composer.editorsOnly')}

    +

    {t('composer.editorsOnly')}

    )} + +
    + ); +} + +/** + * Read-only comments for the anonymous public view (issue #133): fetched from + * the public endpoint (no auth, no composer, no action controls). The section + * is omitted entirely when the page has no comments, so public pages stay clean. + */ +export function PublicComments({ + pondSlug, + pageSlug, +}: { + pondSlug: string; + pageSlug: string; +}): React.JSX.Element | null { + const { t } = useTranslation('comments'); + const query = useQuery({ + queryKey: ['public-comments', pondSlug, pageSlug], + queryFn: () => apiGet(`/public/${pondSlug}/${pageSlug}/comments`), + enabled: Boolean(pondSlug && pageSlug), + retry: false, + }); + + const view = query.data; + if (!view || view.threads.length === 0) return null; + + return ( +
    +
    +

    + {t('title')} + {view.openCount > 0 && {view.openCount}} +

    +
    + +
    + ); +} + +const noop = async (): Promise => {}; + +function CommentThreads({ + view, + pageId, + mayComment, + run, + readOnly, +}: { + view: PageCommentsView | undefined; + pageId: string; + mayComment: boolean; + run: (action: () => Promise) => Promise; + readOnly: boolean; +}): React.JSX.Element { + const { t } = useTranslation('comments'); + const open = (view?.threads ?? []).filter((thread) => !thread.resolved); + const resolved = (view?.threads ?? []).filter((thread) => thread.resolved); + + return ( + <> {view && open.length === 0 && resolved.length === 0 && ( -

    {t('empty')}

    +

    {t('empty')}

    )} -
      +
        {open.map((thread) => ( ))}
      {resolved.length > 0 && ( -
      +
      {t('resolvedSection', { count: resolved.length })} -
        +
          {resolved.map((thread) => ( ))}
      )} -
    + ); } @@ -115,26 +179,42 @@ function Thread({ pageId, mayComment, run, + readOnly, }: { thread: CommentThreadView; pageId: string; mayComment: boolean; run: (action: () => Promise) => Promise; + readOnly: boolean; }): React.JSX.Element { const { t } = useTranslation('comments'); const [replying, setReplying] = useState(false); return (
  • - +
      {thread.replies.map((reply) => (
    • - +
    • ))}
    - {mayComment && !thread.resolved && ( + {!readOnly && mayComment && !thread.resolved && (
    {replying ? ( Promise) => Promise; isRoot: boolean; resolved: boolean; + mayComment: boolean; + readOnly: boolean; }): React.JSX.Element { const { t, i18n } = useTranslation('comments'); const { user } = useAuth(); @@ -205,40 +289,47 @@ function CommentItem({ // Server-sanitized render (shared pipeline, issue #91) — safe by contract.
    )} -
    - {own && !editing && ( - <> - - - - )} - {isRoot && - (resolved ? ( - - ) : ( - - ))} -
    + {!readOnly && ( +
    + {own && !editing && ( + <> + + + + )} + {isRoot && + mayComment && + (resolved ? ( + + ) : ( + + ))} +
    + )} ); } diff --git a/apps/web/src/pages/PageActions.tsx b/apps/web/src/pages/PageActions.tsx index 156b8f0..d8ab604 100644 --- a/apps/web/src/pages/PageActions.tsx +++ b/apps/web/src/pages/PageActions.tsx @@ -7,7 +7,6 @@ import { Ellipsis, FolderInput, History, - MessageSquare, Paperclip, Pencil, Save, @@ -35,9 +34,6 @@ interface PageActionsProps { pondSlug: string; mode: 'view' | 'edit'; onToggleMode: () => void; - unread: number; - showComments: boolean; - onToggleComments: () => void; showAttachments: boolean; onToggleAttachments: () => void; hasTools: boolean; @@ -68,20 +64,6 @@ export function PageActions(props: PageActionsProps): React.JSX.Element { {props.mode === 'edit' && } - 0 - ? `${t('comments:toggle')} — ${t('comments:unread', { count: props.unread })}` - : t('comments:toggle') - } - active={props.showComments} - aria-expanded={props.showComments} - onClick={props.onToggleComments} - > - - {props.unread > 0 && {props.unread}} - void; - onCloseComments: () => void; + /** Reports write access up so the outer page can render the inline + * comments composer (issue #133); the collab mode lives with the provider. */ + onWriteAccess: (canWrite: boolean) => void; }): React.JSX.Element { const { t } = useTranslation('editor'); const { user } = useAuth(); @@ -170,10 +167,12 @@ function PageEditor({ const collab = useCollabProvider(ydoc, page.id); const readOnly = collab.mode === 'ro'; - // `readers` = everyone who can see the page; `editors` = the collab token - // explicitly granted rw (while it is still null, stay conservative — the - // composer appears once the mode resolves). - const mayComment = commentPolicy === 'readers' || collab.mode === 'rw'; + // The collab token's rw grant is the authoritative write signal; report it up + // so the outer page can decide whether to show the inline comment composer + // (issue #133). `readers`-policy ponds let anyone comment regardless. + useEffect(() => { + onWriteAccess(collab.mode === 'rw'); + }, [collab.mode, onWriteAccess]); // A revoked page can no longer be edited (issue #39); the content stays // visible for export via the dialog below. const canEdit = mode === 'edit' && !readOnly && !collab.accessRevoked; @@ -276,9 +275,6 @@ function PageEditor({ onClose={onCloseAttachments} /> )} - {showComments && ( - - )} {/* The connection status renders as an icon in the content footer (left half); the localized text stays for screen readers and as the hover tooltip. */} @@ -340,12 +336,10 @@ export function PageEditorPage(): React.JSX.Element { const [showHistory, setShowHistory] = useState(false); const [showLabels, setShowLabels] = useState(false); const [showAttachments, setShowAttachments] = useState(false); - // Deep link from a comment notification (issue #94): ?comments=1 opens - // the panel immediately. - const [showComments, setShowComments] = useState( - () => new URLSearchParams(window.location.search).get('comments') === '1', - ); const [showPageTools, setShowPageTools] = useState(false); + // Write access reported up by the editor's collab provider, so the outer page + // can decide whether to show the inline comment composer (issue #133). + const [canWrite, setCanWrite] = useState(false); const actionsSlot = usePageActionsSlot(); const queryClient = useQueryClient(); const showToast = useToast(); @@ -460,12 +454,26 @@ export function PageEditorPage(): React.JSX.Element { return () => window.removeEventListener('keydown', onKeyDown); }, [pageId, user, mode, queryClient, t, showToast]); - // TopBar action data (issue #101): the comments badge and the plugin - // page-tools visibility live next to the icons, not inside the editor. - const comments = useComments(resolved?.id); - const unread = showComments || !resolved ? 0 : unreadCount(comments.data, resolved.id, user?.id); + // TopBar action data (issue #101): the plugin page-tools visibility lives + // next to the icons, not inside the editor. const pagePlugins = usePondPlugins(resolved?.pondId); + // Comments are shown inline in the read view (issue #133). `readers`-policy + // ponds let any reader comment; `editors` require the collab rw grant the + // editor reports up via onWriteAccess. + const mayComment = (pond.data?.settings.commentPolicy ?? 'readers') === 'readers' || canWrite; + + // Deep link from a comment notification (issue #94): ?comments=1 scrolls to + // the inline discussion once it has mounted in read mode. + useEffect(() => { + if (mode !== 'view') return; + if (new URLSearchParams(window.location.search).get('comments') !== '1') return; + const timer = window.setTimeout(() => { + document.getElementById('comments')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 200); + return () => window.clearTimeout(timer); + }, [mode, resolved?.id]); + async function saveTitle(): Promise { if (!resolved || title === resolved.title) return; await apiPatch(`/pages/${resolved.id}`, { title }); @@ -506,9 +514,6 @@ export function PageEditorPage(): React.JSX.Element { pondSlug={pondSlug} mode={mode} onToggleMode={() => setMode(mode === 'edit' ? 'view' : 'edit')} - unread={unread} - showComments={showComments} - onToggleComments={() => setShowComments((open) => !open)} showAttachments={showAttachments} onToggleAttachments={() => setShowAttachments((open) => !open)} hasTools={hasPageTools(pagePlugins.data)} @@ -543,12 +548,10 @@ export function PageEditorPage(): React.JSX.Element { page={resolved} mode={mode} pondSlug={pondSlug} - commentPolicy={pond.data?.settings.commentPolicy ?? 'readers'} showAttachments={showAttachments} - showComments={showComments} showPageTools={showPageTools} onCloseAttachments={() => setShowAttachments(false)} - onCloseComments={() => setShowComments(false)} + onWriteAccess={setCanWrite} /> {/* Side panels stack vertically in one column (M10 follow-up). */} {(showLabels || showHistory) && ( @@ -568,8 +571,10 @@ export function PageEditorPage(): React.JSX.Element { )}
    {/* "Linked from" appears below the content in read mode (issue #48); - the local neighborhood graph joins it there (issue #113). */} + the inline discussion (issue #133) and the local neighborhood graph + (issue #113) follow it, in that order. */} {mode === 'view' && } + {mode === 'view' && } {mode === 'view' && ( )} diff --git a/apps/web/src/pages/PublicPageView.tsx b/apps/web/src/pages/PublicPageView.tsx index 1db8ecd..6f48cc5 100644 --- a/apps/web/src/pages/PublicPageView.tsx +++ b/apps/web/src/pages/PublicPageView.tsx @@ -3,6 +3,7 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; +import { PublicComments } from '../comments/CommentsSection'; import { ApiError, apiGet } from '../lib/api'; import { countWords, htmlToText } from '../lib/word-count'; import { NotFoundPage } from './NotFoundPage'; @@ -55,6 +56,8 @@ export function PublicPageView(): React.JSX.Element { {/* The HTML comes from the server's content cache (issue #24), derived from the sanitized editor schema — safe to render. */}
    + {/* Existing comments, read-only for anonymous visitors (issue #133). */} + ); } diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index b09634f..a3135b9 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -522,15 +522,6 @@ button { gap: var(--space-1); } -.icon-button .comments-unread-badge { - position: absolute; - top: -4px; - right: -6px; - margin-left: 0; - line-height: 1.1rem; - padding: 0 0.3rem; -} - .page-actions__more { position: relative; } @@ -3346,8 +3337,8 @@ ul[data-type='task_list'] li > div > p:last-child { margin-top: var(--space-3); } -/* Comments panel (issue #92) */ -.comments-panel { +/* Inline comments section, read view (issues #92, #133) */ +.comments-section { border: 1px solid var(--color-border, #cbd5e1); border-radius: 8px; padding: var(--space-3); @@ -3355,19 +3346,19 @@ ul[data-type='task_list'] li > div > p:last-child { background: var(--color-surface, #fff); } -.comments-panel__header { +.comments-section__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-2); } -.comments-panel__header h2 { +.comments-section__header h2 { margin: 0; font-size: 1.1rem; } -.comments-panel__count { +.comments-section__count { margin-left: var(--space-2); font-size: 0.8125rem; background: var(--color-surface-muted, #e2e8f0); @@ -3375,12 +3366,12 @@ ul[data-type='task_list'] li > div > p:last-child { padding: 0.05rem 0.5rem; } -.comments-panel__policy-hint, -.comments-panel__empty { +.comments-section__policy-hint, +.comments-section__empty { color: var(--color-text-muted); } -.comments-panel__threads { +.comments-section__threads { list-style: none; margin: 0; padding: 0; @@ -3389,16 +3380,16 @@ ul[data-type='task_list'] li > div > p:last-child { gap: var(--space-3); } -.comments-panel__resolved { +.comments-section__resolved { margin-top: var(--space-3); } -.comments-panel__resolved > summary { +.comments-section__resolved > summary { cursor: pointer; color: var(--color-text-muted); } -.comments-panel__resolved > .comments-panel__threads { +.comments-section__resolved > .comments-section__threads { margin-top: var(--space-2); } @@ -3471,15 +3462,6 @@ ul[data-type='task_list'] li > div > p:last-child { color: var(--color-text-muted); } -.comments-unread-badge { - margin-left: var(--space-1); - font-size: 0.75rem; - background: var(--color-primary, #2f6f4f); - color: #fff; - border-radius: 999px; - padding: 0.05rem 0.45rem; -} - .comment-policy__label { display: flex; align-items: center; -- 2.45.2 From a84e9880bd47655d00f37351dcc66b92cd6c49fa Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 19 Jul 2026 03:54:30 +0200 Subject: [PATCH 06/10] #133 Fix: Typfehler im public-Kommentar-Test (noUncheckedIndexedAccess) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit body.threads[0] ist unter noUncheckedIndexedAccess möglicherweise undefined; per Destrukturierung + Non-null-Assertion nach dem toHaveLength(1)-Check geglättet. (Der Fehler rutschte durch, weil der #133-Commit nach dem Nachtragen des Tests nicht erneut getypecheckt wurde.) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/api/src/public/public.e2e.db.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/api/src/public/public.e2e.db.test.ts b/apps/api/src/public/public.e2e.db.test.ts index ed41f26..86f8860 100644 --- a/apps/api/src/public/public.e2e.db.test.ts +++ b/apps/api/src/public/public.e2e.db.test.ts @@ -131,8 +131,9 @@ describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => { }; expect(body.openCount).toBe(1); expect(body.threads).toHaveLength(1); - expect(body.threads[0].root.body).toBe('A public remark'); - expect(body.threads[0].replies.map((r) => r.body)).toEqual(['A public reply']); + const [thread] = body.threads; + expect(thread!.root.body).toBe('A public remark'); + expect(thread!.replies.map((r) => r.body)).toEqual(['A public reply']); // A non-public page never reveals its comments either. await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/comments`).expect(404); -- 2.45.2 From 15376d4ac2d11d71f49014448a7c77f8ab363f19 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 19 Jul 2026 04:01:18 +0200 Subject: [PATCH 07/10] #135 Seiten-Einbettung ![[Seite]] (Transklusion) im Lesemodus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Obsidian-Syntax `![[slug]]` (optional `![[slug|Anzeige]]`) als Seiten- Einbettung. Im Lese- und öffentlichen Modus wird der Inhalt der Zielseite inline gerendert; im Editier-Modus zeigt die NodeView eine Platzhalter- Karte (Titel + Öffnen-Link). Shared (Vorbild plugin_block): - Neuer Block-Atom-Node `transclusion` (targetSlug + optional displayText). - Markdown: Block-Regel für eine reine `![[…]]`-Zeile (vor `paragraph` registriert; mitten im Absatz greift sie bewusst nicht), Token→Node- Mapping, Serializer — Round-Trip stabil. - html.ts: Platzhalter `
    `. - extractWikilinkSlugs erfasst jetzt auch Transklusionen → Einbettung zählt als Backlink/Graph-Kante. Backend (zentraler Render-Pfad): - PublicService expandiert Platzhalter zur gerenderten Body-HTML der Zielseite: SELBER Pond, read-permission-geprüft, Tiefe ≤2 + Zyklen- Guard (visited); Fehlend/unlesbar/zyklisch → neutraler Wikilink. Medien werden EINMAL über den ganzen Baum aufgelöst (kein Doppel-Processing). - Neuer authentifizierter Endpoint GET /read/:pondSlug/:pageSlug (nicht @Public) liefert dieselbe gerenderte HTML — für die NodeView im authentifizierten Lesemodus, auch bei nicht-öffentlichen Seiten. Web: - NodeView `transclusion.tsx`: Editier-Modus → Karte; Lesemodus → holt /read/:pond/:slug und rendert den (server-sanitisierten) Inhalt inline. - WikilinkAutocomplete unterstützt `![[` → fügt einen Transklusions-Block ein (statt Wikilink). - CSS für Karte (.dt-transclusion-card) und Embed (.dt-embed), i18n de+en. Tests: shared Round-Trip-Unit (5), public-DB-Test um Embed-Expansion (zyklus-sicher, Fehlend→Link) erweitert — grün. typecheck/lint/i18n grün. Visuelle Editor-Verifikation folgt auf dem Test-Stage. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/api/src/public/public.e2e.db.test.ts | 30 +++++ apps/api/src/public/public.module.ts | 3 +- apps/api/src/public/public.service.ts | 105 +++++++++++++++++- .../api/src/public/read-content.controller.ts | 27 +++++ apps/web/src/editor/WikilinkAutocomplete.tsx | 47 +++++--- apps/web/src/editor/document-extensions.ts | 2 + apps/web/src/editor/nodes/transclusion.tsx | 88 +++++++++++++++ apps/web/src/styles/base.css | 52 +++++++++ packages/shared/i18n/de/editor.json | 4 + packages/shared/i18n/en/editor.json | 4 + packages/shared/src/editor-schema/html.ts | 9 ++ packages/shared/src/editor-schema/markdown.ts | 45 ++++++++ packages/shared/src/editor-schema/schema.ts | 39 ++++++- .../src/editor-schema/transclusion.test.ts | 50 +++++++++ .../shared/src/editor-schema/wikilinks.ts | 12 +- 15 files changed, 489 insertions(+), 28 deletions(-) create mode 100644 apps/api/src/public/read-content.controller.ts create mode 100644 apps/web/src/editor/nodes/transclusion.tsx create mode 100644 packages/shared/src/editor-schema/transclusion.test.ts diff --git a/apps/api/src/public/public.e2e.db.test.ts b/apps/api/src/public/public.e2e.db.test.ts index 86f8860..9cb5a39 100644 --- a/apps/api/src/public/public.e2e.db.test.ts +++ b/apps/api/src/public/public.e2e.db.test.ts @@ -139,6 +139,36 @@ describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => { await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/comments`).expect(404); }); + it('expands a page embed to the target’s content, cycle-safe (issue #135)', async () => { + const embeddedSlug = `embedded-${suffix}`; + const hostSlug = `host-${suffix}`; + // The embedded page embeds the host back — the expansion must terminate. + await makePage( + pondId, + embeddedSlug, + 'Embedded', + `

    Body of the embedded page.

    ` + + `
    Host
    `, + ); + await makePage( + pondId, + hostSlug, + 'Host', + `

    Before.

    ` + + `
    Embedded
    ` + + `
    Missing
    `, + ); + + const res = await api().get(`/api/v1/public/${pondSlug}/${hostSlug}/content`).expect(200); + const html = (res.body as { html: string }).html; + // The embedded page's body is spliced in, wrapped as an embed. + expect(html).toContain('Body of the embedded page.'); + expect(html).toContain('class="dt-embed"'); + // No raw placeholder survives; a missing target degrades to a link. + expect(html).not.toContain('dt-transclusion'); + expect(html).toContain(`data-wikilink="ghost-${suffix}"`); + }); + it('404s all public endpoints once the public grant is removed', async () => { await prisma.roleGrant.delete({ where: { id: publicGrantId } }); // The API route invalidates on its own mutations; this test deletes diff --git a/apps/api/src/public/public.module.ts b/apps/api/src/public/public.module.ts index 91c1604..c333f07 100644 --- a/apps/api/src/public/public.module.ts +++ b/apps/api/src/public/public.module.ts @@ -5,6 +5,7 @@ import { PluginsModule } from '../plugins/plugins.module'; import { PublicController } from './public.controller'; import { PublicService } from './public.service'; +import { ReadContentController } from './read-content.controller'; /** * Public read access (issue #56): anonymous-reachable page endpoints on top of @@ -14,7 +15,7 @@ import { PublicService } from './public.service'; */ @Module({ imports: [PluginsModule, CommentsModule], - controllers: [PublicController], + controllers: [PublicController, ReadContentController], providers: [PublicService], }) export class PublicModule {} diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index ad6faa8..fa33498 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -61,25 +61,86 @@ export class PublicService { return { pond, page }; } - /** The page content for the SPA's read-only public view. */ + /** The page content for the read view (public and authenticated, issue #56). */ async content(user: User | null, pondSlug: string, pageSlug: string): Promise { const { pond, page } = await this.resolve(user, pondSlug, pageSlug); const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); - // Plugin blocks render their static form (#79), and the pond's active - // section-style CSS travels inline — the public view loads no plugin - // runtime, and the CSS passed the install gate's scoping rules. - const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? ''); + // The pond's active section-style CSS travels inline — the read view loads + // no plugin runtime, and the CSS passed the install gate's scoping rules. const styleTag = await this.fallbacks.sectionStyleTag(pond.id); + // Plugin blocks render their static form (#79) and page embeds expand to the + // target's rendered HTML (#135), then media is resolved once over the whole + // tree. `visited` seeds with this page so an embed of self is not expanded. + const body = await this.renderBody(user, pond.id, page, 0, new Set([page.slug])); return { pondName: pond.name, pondSlug: pond.slug, title: page.title, slug: page.slug, - html: styleTag + resolveMediaUrls(withFallbacks), + html: styleTag + resolveMediaUrls(body), updatedAt: (cache?.updatedAt ?? new Date()).toISOString(), }; } + /** Longest embed chain we follow before falling back to a link (issue #135). */ + private static readonly MAX_EMBED_DEPTH = 2; + + /** + * A page's body HTML: cached HTML + plugin fallbacks + expanded page embeds, + * but WITHOUT media resolution or the style tag — those are applied once at + * the top of {@link content} so nested embeds are not double-processed. + */ + private async renderBody( + user: User | null, + pondId: string, + page: { id: string }, + depth: number, + visited: Set, + ): Promise { + const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); + const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? ''); + return this.expandEmbeds(withFallbacks, user, pondId, depth, visited); + } + + /** + * Replaces each `dt-transclusion` placeholder (issue #135) with the target + * page's rendered body. Same-pond only, read-permission-checked; a missing, + * unreadable, cyclic, or too-deep target degrades to a plain link so the page + * never leaks existence and never loops. + */ + private async expandEmbeds( + html: string, + user: User | null, + pondId: string, + depth: number, + visited: Set, + ): Promise { + const placeholder = /
    [^<]*<\/div>/g; + return replaceAsync(html, placeholder, async (_match, rawSlug) => { + const slug = rawSlug as string; + const target = await this.prisma.page.findFirst({ + where: { pondId, slug, deletedAt: null }, + select: { id: true, pondId: true, slug: true, title: true }, + }); + const readable = target && (await this.permissions.canAccessPage(user, target, 'read')); + if (!target || !readable || depth >= PublicService.MAX_EMBED_DEPTH || visited.has(slug)) { + return embedLink(slug, target?.title ?? slug); + } + const inner = await this.renderBody( + user, + pondId, + target, + depth + 1, + new Set(visited).add(slug), + ); + return ( + `` + ); + }); + } + /** * The page's comments for the anonymous public view (issue #133), read-only. * `resolve()` enforces (possibly anonymous) read access — a non-public page @@ -127,3 +188,35 @@ function resolveMediaUrls(html: string): string { 'src="/api/v1/media/$1" data-file-id="$1"', ); } + +/** The fallback for an embed that cannot expand (missing/unreadable/cyclic/too + * deep, issue #135): a plain wikilink, so the page never loops or leaks. */ +function embedLink(slug: string, label: string): string { + const safeSlug = escapeHtml(slug); + return ( + `` + ); +} + +/** `String.replace` with an async replacer (issue #135): resolves every match's + * replacement in parallel, then splices them back in match order. */ +async function replaceAsync( + input: string, + regex: RegExp, + replacer: (match: string, ...groups: string[]) => Promise, +): Promise { + const matches = [...input.matchAll(regex)]; + if (matches.length === 0) return input; + const replacements = await Promise.all( + matches.map((match) => replacer(match[0], ...match.slice(1))), + ); + let result = ''; + let lastIndex = 0; + matches.forEach((match, i) => { + result += input.slice(lastIndex, match.index) + replacements[i]; + lastIndex = (match.index ?? 0) + match[0].length; + }); + return result + input.slice(lastIndex); +} diff --git a/apps/api/src/public/read-content.controller.ts b/apps/api/src/public/read-content.controller.ts new file mode 100644 index 0000000..f827af6 --- /dev/null +++ b/apps/api/src/public/read-content.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Param, Req } from '@nestjs/common'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { PublicPageContent, PublicService } from './public.service'; + +/** + * Authenticated read-rendering (issue #135). Returns a page's rendered read + * HTML — plugin fallbacks, expanded page embeds, resolved media — for a + * signed-in viewer with read access. The transclusion node view fetches this to + * show an embedded page's content inline in the authenticated read view, which + * must also work for pages that are not public and so are out of reach of the + * `/public` endpoints. NOT `@Public`: the auth guard requires a session and the + * service enforces read permission (a non-readable page 404s, no leak). + */ +@Controller('read') +export class ReadContentController { + constructor(private readonly publicPages: PublicService) {} + + @Get(':pondSlug/:pageSlug') + async content( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Req() request: AuthedRequest, + ): Promise { + return this.publicPages.content(request.user ?? null, pondSlug, pageSlug); + } +} diff --git a/apps/web/src/editor/WikilinkAutocomplete.tsx b/apps/web/src/editor/WikilinkAutocomplete.tsx index 517d602..18f1a8e 100644 --- a/apps/web/src/editor/WikilinkAutocomplete.tsx +++ b/apps/web/src/editor/WikilinkAutocomplete.tsx @@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next'; import { useWikilinks } from './wikilink-context'; -/** An open `[[` context: the query typed so far and where its `[[` began. */ +/** An open `[[` context: the query typed so far and where its `[[` began. + * `embed` is true when it was opened as `![[` — a page embed (issue #135). */ interface QueryState { query: string; from: number; + embed: boolean; coords: { left: number; bottom: number }; } @@ -16,18 +18,21 @@ interface QueryState { type Suggestion = { kind: 'page'; slug: string; label: string } | { kind: 'create'; slug: string; label: string }; -/** Detects a `[[query` immediately before a collapsed cursor (issue #46). */ -function detectQuery(editor: Editor): { query: string; from: number } | null { +/** Detects a `[[query` (link) or `![[query` (embed, #135) immediately before a + * collapsed cursor (issue #46). The optional leading `!` opens an embed. */ +function detectQuery(editor: Editor): { query: string; from: number; embed: boolean } | null { const { selection } = editor.state; if (!selection.empty) return null; const $from = selection.$from; if (!$from.parent.isTextblock) return null; const start = Math.max(0, $from.parentOffset - 200); const before = $from.parent.textBetween(start, $from.parentOffset, undefined, ''); - const match = /\[\[([^[\]\n]*)$/.exec(before); + const match = /(!?)\[\[([^[\]\n]*)$/.exec(before); if (!match) return null; - const query = match[1] ?? ''; - return { query, from: selection.from - query.length - 2 }; + const embed = match[1] === '!'; + const query = match[2] ?? ''; + // `[[` is 2 chars; an embed's leading `!` is one more to swallow. + return { query, from: selection.from - query.length - 2 - (embed ? 1 : 0), embed }; } /** @@ -70,14 +75,28 @@ export function WikilinkAutocomplete({ editor }: { editor: Editor }): React.JSX. function choose(item: Suggestion | undefined): void { const current = live.current.state; if (!item || !current) return; - editor - .chain() - .focus() - .insertContentAt({ from: current.from, to: editor.state.selection.from }, [ - { type: 'wikilink', attrs: { targetSlug: item.slug, displayText: null } }, - { type: 'text', text: ' ' }, - ]) - .run(); + const range = { from: current.from, to: editor.state.selection.from }; + if (current.embed) { + // A page embed is a block node (#135) — replace the typed `![[query` with + // the transclusion block; ProseMirror lifts it out of the paragraph. + editor + .chain() + .focus() + .insertContentAt(range, { + type: 'transclusion', + attrs: { targetSlug: item.slug, displayText: null }, + }) + .run(); + } else { + editor + .chain() + .focus() + .insertContentAt(range, [ + { type: 'wikilink', attrs: { targetSlug: item.slug, displayText: null } }, + { type: 'text', text: ' ' }, + ]) + .run(); + } close(); } diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts index 7408dd7..4a8730c 100644 --- a/apps/web/src/editor/document-extensions.ts +++ b/apps/web/src/editor/document-extensions.ts @@ -7,6 +7,7 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists'; import { PluginBlock } from './nodes/plugin-block'; import { Table, TableCell, TableHeader, TableRow } from './nodes/table'; import { TaskItem } from './nodes/task-item'; +import { Transclusion } from './nodes/transclusion'; import { Wikilink } from './nodes/wikilink'; import { Blockquote, @@ -43,6 +44,7 @@ export const documentExtensions: AnyExtension[] = [ Image, PluginBlock, Wikilink, + Transclusion, Table, TableRow, TableCell, diff --git a/apps/web/src/editor/nodes/transclusion.tsx b/apps/web/src/editor/nodes/transclusion.tsx new file mode 100644 index 0000000..4cd0d6e --- /dev/null +++ b/apps/web/src/editor/nodes/transclusion.tsx @@ -0,0 +1,88 @@ +import { useQuery } from '@tanstack/react-query'; +import { Node } from '@tiptap/core'; +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; +import type { NodeViewProps } from '@tiptap/react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +import { apiGet } from '../../lib/api'; +import { attributesFromSpec, nodeSpec } from '../spec-utils'; +import { useWikilinks } from '../wikilink-context'; + +interface RenderedContent { + title: string; + html: string; +} + +/** + * Renders a `![[page embed]]` (issue #135). In edit mode — and while loading or + * when the target is missing/unreadable — it shows a compact placeholder card + * (title + open link) so writing stays fast. In read mode it fetches the + * target's server-rendered HTML (`/read/:pond/:slug`, permission-checked, with + * nested embeds already expanded) and shows it inline. + */ +function TransclusionView({ node, editor }: NodeViewProps): React.JSX.Element { + const { t } = useTranslation('editor'); + const { resolve, pondSlug } = useWikilinks(); + + const slug = node.attrs.targetSlug as string; + const display = node.attrs.displayText as string | null; + const { title, exists } = resolve(slug); + const label = display ?? title ?? slug; + const editable = editor.isEditable; + + const content = useQuery({ + queryKey: ['rendered', pondSlug, slug], + queryFn: () => apiGet(`/read/${pondSlug}/${slug}`), + enabled: !editable && exists, + retry: false, + }); + + if (editable || !exists || content.isError) { + return ( + + + ⧉ + + + {t('transclusion.embedded', { title: label })} + + + {t('transclusion.open')} + + + ); + } + + return ( + +
    + + {content.data?.title ?? label} + +
    + {content.data ? ( + // Server-sanitized read HTML (shared docToHtml pipeline) — safe by + // contract, same as the public view. +
    + ) : ( +
    + )} + + ); +} + +const transclusionSpec = nodeSpec('transclusion'); +export const Transclusion = Node.create({ + name: 'transclusion', + group: transclusionSpec.group, + atom: transclusionSpec.atom, + addAttributes() { + return attributesFromSpec(transclusionSpec); + }, + parseHTML: () => transclusionSpec.parseDOM, + renderHTML: ({ node }) => transclusionSpec.toDOM!(node), + addNodeView() { + return ReactNodeViewRenderer(TransclusionView); + }, +}); diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index a3135b9..43a6602 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -2383,6 +2383,58 @@ ul[data-type='task_list'] li > div > p:last-child { border-bottom: 1px dashed currentColor; } +/* Page embed / transclusion (issue #135). Placeholder card shown in the editor + and while loading; the expanded embed wraps the target's rendered content. */ +.dt-transclusion-card { + display: flex; + align-items: center; + gap: var(--space-2); + margin: var(--space-3) 0; + padding: var(--space-2) var(--space-3); + border: 1px dashed var(--color-border); + border-radius: 8px; + background: var(--color-bg-subtle); + color: var(--color-text-muted); + font-size: 0.9375rem; +} + +.dt-transclusion-card__icon { + font-size: 1.1em; +} + +.dt-transclusion-card__label { + flex: 1; + min-width: 0; +} + +.dt-transclusion-card__open { + color: var(--color-accent); + text-decoration: none; + white-space: nowrap; +} + +.dt-embed { + margin: var(--space-3) 0; + padding: var(--space-3); + border: 1px solid var(--color-border); + border-left: 3px solid var(--color-accent); + border-radius: 8px; + background: var(--color-surface, #fff); +} + +.dt-embed__title { + margin-bottom: var(--space-2); + font-weight: var(--font-weight-heading); +} + +.dt-embed__body > :first-child { + margin-top: 0; +} + +.dt-embed__body > :last-child { + margin-bottom: 0; +} + .wikilink-suggest { list-style: none; margin: 0; diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index ebddc6a..379b840 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -173,6 +173,10 @@ "autocompleteLabel": "Auf eine Seite verlinken", "createHint": "Seite „{{title}}“ anlegen" }, + "transclusion": { + "embedded": "Eingebettet: {{title}}", + "open": "Öffnen" + }, "notFound": { "hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.", "create": "Seite „{{slug}}“ anlegen" diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index 765615f..9684ddf 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -173,6 +173,10 @@ "autocompleteLabel": "Link to a page", "createHint": "Create page “{{title}}”" }, + "transclusion": { + "embedded": "Embedded: {{title}}", + "open": "Open" + }, "notFound": { "hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.", "create": "Create the page “{{slug}}”" diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts index 06d01b8..9f79ec3 100644 --- a/packages/shared/src/editor-schema/html.ts +++ b/packages/shared/src/editor-schema/html.ts @@ -132,6 +132,15 @@ function renderBlock(node: Node): string { ` data-plugin-data="${data}">[${pluginId}/${blockType}]
    ` ); } + case 'transclusion': { + // A page embed (#135). The static HTML is a placeholder carrying the + // target slug; the read view / public renderer expands it server-side to + // the target page's rendered HTML (permission-checked, recursion limited). + // Left un-expanded (here) it degrades to a labelled block. + const slug = escapeHtml(node.attrs.targetSlug as string); + const display = node.attrs.displayText ? escapeHtml(node.attrs.displayText as string) : slug; + return `
    ${display}
    `; + } case 'code_block': return `
    ${escapeHtml(node.textContent)}
    `; case 'horizontal_rule': diff --git a/packages/shared/src/editor-schema/markdown.ts b/packages/shared/src/editor-schema/markdown.ts index fecd92c..5bb7565 100644 --- a/packages/shared/src/editor-schema/markdown.ts +++ b/packages/shared/src/editor-schema/markdown.ts @@ -192,6 +192,35 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean { return true; } +/** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page (#135). */ +const TRANSCLUSION_LINE = /^!\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/; + +/** + * Block rule for page embeds (issue #135). A line consisting solely of + * `![[slug]]` becomes a `transclusion` block node; anything else (including + * `![[x]]` mid-paragraph) is left untouched. Registered before `paragraph` so + * the lone-embed line is not swallowed as ordinary text. + */ +function transclusionRule( + state: StateBlock, + startLine: number, + _endLine: number, + silent: boolean, +): boolean { + const start = state.bMarks[startLine]! + state.tShift[startLine]!; + const max = state.eMarks[startLine]!; + const match = TRANSCLUSION_LINE.exec(state.src.slice(start, max)); + if (!match) return false; + if (silent) return true; + const token = state.push('transclusion', '', 0); + token.attrSet('target', match[1]!.trim()); + const display = match[2]?.trim(); + if (display) token.attrSet('display', display); + token.map = [startLine, startLine + 1]; + state.line = startLine + 1; + return true; +} + /** Opening fence of a section-style container: `::: {data-section-style="p/s"}`. */ const SECTION_OPEN = /^:::+\s*\{\s*data-section-style="([^"/]+)\/([^"]+)"\s*\}\s*$/; const SECTION_CLOSE = /^:::+\s*$/; @@ -248,6 +277,9 @@ function createTokenizer(): MarkdownIt { const md = new MarkdownIt('default', { html: false }); // Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links. md.inline.ruler.before('link', 'wikilink', wikilinkRule); + // Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads + // as plain text (issue #135). + md.block.ruler.before('paragraph', 'transclusion', transclusionRule); // Run before `fence` so `:::` is not read as a code fence. md.block.ruler.before('fence', 'section', sectionRule); const rawParse = md.parse.bind(md); @@ -312,6 +344,13 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), { displayText: tok.attrGet('display') || null, }), }, + transclusion: { + node: 'transclusion', + getAttrs: (tok) => ({ + targetSlug: tok.attrGet('target') ?? '', + displayText: tok.attrGet('display') || null, + }), + }, em: { mark: 'italic' }, strong: { mark: 'bold' }, s: { mark: 'strikethrough' }, @@ -435,6 +474,12 @@ const markdownSerializer = new MarkdownSerializer( const display = node.attrs.displayText as string | null; state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`); }, + transclusion(state, node) { + const slug = node.attrs.targetSlug as string; + const display = node.attrs.displayText as string | null; + state.write(display ? `![[${slug}|${display}]]` : `![[${slug}]]`); + state.closeBlock(node); + }, hard_break(state, node, parent, index) { for (let i = index + 1; i < parent.childCount; i += 1) { if (parent.child(i).type !== node.type) { diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts index a6d3200..543cddd 100644 --- a/packages/shared/src/editor-schema/schema.ts +++ b/packages/shared/src/editor-schema/schema.ts @@ -7,8 +7,9 @@ import { tableNodes } from 'prosemirror-tables'; * validation (ADR 0008) all import this schema instead of defining their * own, so "valid document" means the same thing everywhere. * - * Node names `wikilink` and `plugin_block` are reserved for these features - * (wikilinks #46, plugin-defined block types #76) — do not repurpose them. + * Node names `wikilink`, `plugin_block`, and `transclusion` are reserved for + * these features (wikilinks #46, plugin-defined block types #76, page embeds + * #135) — do not repurpose them. */ export const editorSchema = new Schema({ nodes: { @@ -255,6 +256,40 @@ export const editorSchema = new Schema({ }, }, + // Obsidian-style page embed `![[slug]]` (issue #135). A block atom that + // references another page by slug; the read view / public renderer expands + // it to the target page's rendered HTML (permission-checked, recursion + // limited), while the editor shows a placeholder card. Like `wikilink` it + // stores slug + optional display text only — live resolution happens where + // the pond's pages are known. + transclusion: { + group: 'block', + atom: true, + attrs: { + targetSlug: { validate: 'string' }, + displayText: { default: null }, + }, + parseDOM: [ + { + tag: 'div[data-transclusion]', + getAttrs: (dom) => ({ + targetSlug: dom.getAttribute('data-transclusion'), + displayText: dom.getAttribute('data-display') || null, + }), + }, + ], + toDOM: (node) => { + const slug = node.attrs.targetSlug as string; + const display = node.attrs.displayText as string | null; + const attrs: Record = { + 'data-transclusion': slug, + class: 'dt-transclusion', + }; + if (display) attrs['data-display'] = display; + return ['div', attrs, display ?? slug]; + }, + }, + ...tableNodes({ tableGroup: 'block', cellContent: 'block+', cellAttributes: {} }), }, diff --git a/packages/shared/src/editor-schema/transclusion.test.ts b/packages/shared/src/editor-schema/transclusion.test.ts new file mode 100644 index 0000000..a92e79b --- /dev/null +++ b/packages/shared/src/editor-schema/transclusion.test.ts @@ -0,0 +1,50 @@ +import { Node } from 'prosemirror-model'; +import { describe, expect, it } from 'vitest'; + +import { docToHtml } from './html'; +import { docToMarkdown, markdownToDoc } from './markdown'; +import { extractWikilinkSlugs } from './wikilinks'; + +function firstTransclusion(doc: Node): Node | null { + let found: Node | null = null; + doc.descendants((node) => { + if (!found && node.type.name === 'transclusion') found = node; + }); + return found; +} + +describe('page embed / transclusion (issue #135)', () => { + it('parses a lone ![[slug]] line to a transclusion block and round-trips', () => { + const doc = markdownToDoc('Intro\n\n![[rennrad]]\n\nOutro'); + const node = firstTransclusion(doc); + expect(node).not.toBeNull(); + expect(node!.attrs.targetSlug).toBe('rennrad'); + expect(node!.attrs.displayText).toBeNull(); + expect(docToMarkdown(doc)).toContain('![[rennrad]]'); + }); + + it('supports optional display text and round-trips it', () => { + const doc = markdownToDoc('![[rennrad|Mein Rad]]'); + const node = firstTransclusion(doc); + expect(node!.attrs.targetSlug).toBe('rennrad'); + expect(node!.attrs.displayText).toBe('Mein Rad'); + expect(docToMarkdown(doc).trim()).toBe('![[rennrad|Mein Rad]]'); + }); + + it('renders a placeholder div carrying the slug', () => { + const doc = markdownToDoc('![[rennrad]]'); + const html = docToHtml(doc); + expect(html).toContain('class="dt-transclusion"'); + expect(html).toContain('data-transclusion="rennrad"'); + }); + + it('registers the embed target as an outgoing link (backlinks/graph)', () => { + const doc = markdownToDoc('![[rennrad]]\n\n[[dota]]'); + expect(extractWikilinkSlugs(doc)).toEqual(['rennrad', 'dota']); + }); + + it('only embeds a lone line — mid-paragraph ![[x]] is not a transclusion', () => { + const doc = markdownToDoc('see ![[rennrad]] inline'); + expect(firstTransclusion(doc)).toBeNull(); + }); +}); diff --git a/packages/shared/src/editor-schema/wikilinks.ts b/packages/shared/src/editor-schema/wikilinks.ts index b296079..a5648c3 100644 --- a/packages/shared/src/editor-schema/wikilinks.ts +++ b/packages/shared/src/editor-schema/wikilinks.ts @@ -1,16 +1,18 @@ import { Node } from 'prosemirror-model'; /** - * The distinct target slugs of every `[[wikilink]]` in a document (issue #47). - * Used server-side to maintain the `page_links` index on every content change, - * so backlinks and phantom (missing-target) links can be queried. Order of - * first appearance is preserved; duplicates are collapsed. + * The distinct target slugs of every `[[wikilink]]` and every `![[embed]]` + * (transclusion, issue #135) in a document (issue #47). Used server-side to + * maintain the `page_links` index on every content change, so backlinks and + * phantom (missing-target) links can be queried — an embed is semantically a + * reference, so it registers as a link too. Order of first appearance is + * preserved; duplicates are collapsed. */ export function extractWikilinkSlugs(doc: Node): string[] { const slugs: string[] = []; const seen = new Set(); doc.descendants((node) => { - if (node.type.name === 'wikilink') { + if (node.type.name === 'wikilink' || node.type.name === 'transclusion') { const slug = node.attrs.targetSlug as string; if (slug && !seen.has(slug)) { seen.add(slug); -- 2.45.2 From c164a031e4988645f33e0d54f601da53a505a184 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 19 Jul 2026 04:18:04 +0200 Subject: [PATCH 08/10] #136 Excalidraw-Block-Plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Referenz-Block-Plugin „Excalidraw" (handgezeichnete Whiteboard- Skizzen), analog zum draw.io-Plugin. Anders als draw.io (vendored Webapp) ist Excalidraw eine React-npm-Lib: esbuild bündelt Controller + React + Excalidraw in plugin.js, die Font-/Locale-/Data-Assets werden aus node_modules in den ZIP-Root kopiert und zur Laufzeit über EXCALIDRAW_ASSET_PATH (Plugin-Asset-Basis) geladen — nichts spricht mit excalidraw.com, die Sandbox-CSP pinnt jede Anfrage auf self. - manifest.json: kind=code, Block-Extension-Point diagram, permissions blockData+ui, fallback "[Excalidraw]". - src/plugin.tsx: Render-Modus zeigt gespeichertes SVG; Edit-Modus zeigt Snapshot + Bearbeiten-Knopf (leerer Block öffnet direkt); Vollbild via host.ui.enterFullscreen mountet (React), „Speichern & Beenden" exportiert per exportToSvg, persistiert {scene, svg} über host.blockData.setData → Fallback-Renderer bedient Lese-/Public-Ansicht + Exporte ohne Backend-Änderung. - build.mjs: esbuild (jsx automatic, css→text, production-conditions) + fflate-ZIP. Build erzeugt excalidraw-1.0.0.zip: 15,5 MiB zip / 22,3 MiB unpacked (Limits 64/256 MiB — passt). - i18n de+en, globals.d.ts (CSS-Modul-Deklaration). pnpm-Override @floating-ui/react-dom@2.1.2: Excalidraw 0.18.1 zieht sonst @floating-ui/dom@^1.8.0, das (noch) nicht im Registry ist und `pnpm install` repo-weit bricht (dokumentiert in pnpm-workspace.yaml). VERIFIZIERT: typecheck/lint, Manifest-Validierung (SDK), Build+ZIP-Größe. NICHT lokal verifiziert (braucht Preview/Test-Stage): Laufzeit — Excalidraw-Rendering + Speichern unter Sandbox-CSP, Font-Laden vom Asset-Pfad. Prod-Installation macht Stefan als Site-Admin. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- packages/plugins/excalidraw/.gitignore | 2 + packages/plugins/excalidraw/build.mjs | 115 ++ packages/plugins/excalidraw/i18n/de.json | 10 + packages/plugins/excalidraw/i18n/en.json | 10 + packages/plugins/excalidraw/manifest.json | 19 + packages/plugins/excalidraw/package.json | 25 + packages/plugins/excalidraw/src/globals.d.ts | 4 + packages/plugins/excalidraw/src/plugin.tsx | 268 ++++ packages/plugins/excalidraw/tsconfig.json | 12 + pnpm-lock.yaml | 1249 ++++++++++++++++++ pnpm-workspace.yaml | 8 + 11 files changed, 1722 insertions(+) create mode 100644 packages/plugins/excalidraw/.gitignore create mode 100644 packages/plugins/excalidraw/build.mjs create mode 100644 packages/plugins/excalidraw/i18n/de.json create mode 100644 packages/plugins/excalidraw/i18n/en.json create mode 100644 packages/plugins/excalidraw/manifest.json create mode 100644 packages/plugins/excalidraw/package.json create mode 100644 packages/plugins/excalidraw/src/globals.d.ts create mode 100644 packages/plugins/excalidraw/src/plugin.tsx create mode 100644 packages/plugins/excalidraw/tsconfig.json diff --git a/packages/plugins/excalidraw/.gitignore b/packages/plugins/excalidraw/.gitignore new file mode 100644 index 0000000..224f6c1 --- /dev/null +++ b/packages/plugins/excalidraw/.gitignore @@ -0,0 +1,2 @@ +vendor/ +dist/ diff --git a/packages/plugins/excalidraw/build.mjs b/packages/plugins/excalidraw/build.mjs new file mode 100644 index 0000000..17a2122 --- /dev/null +++ b/packages/plugins/excalidraw/build.mjs @@ -0,0 +1,115 @@ +// Builds the installable Excalidraw plugin. Unlike draw.io (which vendors a +// standalone webapp) Excalidraw is an npm React component: esbuild bundles the +// controller + React + Excalidraw into plugin.js, and its font/worker assets +// are copied from node_modules into `excalidraw-assets/` so the runtime loads +// them from the plugin's own asset path (EXCALIDRAW_ASSET_PATH) — nothing ever +// talks to excalidraw.com, and the sandbox CSP pins every request to self. +import { createRequire } from 'node:module'; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, relative } 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')); +const require = createRequire(import.meta.url); + +// --- 1. Locate Excalidraw's prebuilt font/worker assets -------------------- +// The package's `exports` map hides ./package.json, so resolve the main entry +// and walk up to the package root instead. +function packageRootOf(specifier) { + let dir = dirname(require.resolve(specifier)); + while (dir !== dirname(dir)) { + const pj = join(dir, 'package.json'); + if (existsSync(pj)) { + try { + if (JSON.parse(readFileSync(pj, 'utf8')).name === specifier) return dir; + } catch { + // keep walking up + } + } + dir = dirname(dir); + } + throw new Error(`package root not found for ${specifier}`); +} +const excalidrawPkg = packageRootOf('@excalidraw/excalidraw'); +const prodDir = join(excalidrawPkg, 'dist', 'prod'); +if (!existsSync(prodDir)) { + throw new Error(`Excalidraw prod build not found at ${prodDir}. Check the dist layout.`); +} +// Runtime assets loaded relative to EXCALIDRAW_ASSET_PATH (set to the plugin's +// asset base in plugin.tsx): fonts render the sketch, locales translate the +// editor UI, data holds font metadata. Copied to the ZIP root so the sandbox +// CSP (self only) can serve them. +const ASSET_SUBDIRS = ['fonts', 'locales', 'data']; + +/** Recursively collect files below `dir` (absolute), keyed by path relative to + * it, each prefixed with `prefix`. */ +function collect(dir, prefix) { + const files = {}; + for (const name of readdirSync(dir)) { + const abs = join(dir, name); + const rel = `${prefix}${name}`; + if (statSync(abs).isDirectory()) Object.assign(files, collect(abs, `${rel}/`)); + else files[rel] = readFileSync(abs); + } + return files; +} +const assetFiles = {}; +for (const sub of ASSET_SUBDIRS) { + const dir = join(prodDir, sub); + if (existsSync(dir)) Object.assign(assetFiles, collect(dir, `${sub}/`)); +} + +// --- 2. Bundle the plugin controller (React + Excalidraw) ------------------ +mkdirSync(join(root, 'dist'), { recursive: true }); +await build({ + entryPoints: [join(root, 'src/plugin.tsx')], + bundle: true, + format: 'esm', + platform: 'browser', + // Excalidraw's `exports` gate index.js / index.css behind `production` / + // `development` conditions (no `default`); select the production build. + conditions: ['production'], + outfile: join(root, 'dist/plugin.js'), + minify: true, + jsx: 'automatic', + loader: { '.css': 'text', '.woff2': 'dataurl', '.ttf': 'dataurl', '.svg': 'text' }, + define: { + 'process.env.NODE_ENV': '"production"', + 'process.env.IS_PREACT': '"false"', + }, +}); + +// --- 3. Pack the ZIP -------------------------------------------------------- +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)); +} +// Asset paths already carry their subdir prefix (fonts/…, locales/…, data/…) +// and sit at the ZIP root so they resolve under EXCALIDRAW_ASSET_PATH. +Object.assign(files, assetFiles); + +const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`); +rmSync(target, { force: true }); +writeFileSync(target, zipSync(files, { level: 6 })); +const unpacked = Object.values(files).reduce((sum, bytes) => sum + bytes.length, 0); +console.log( + `wrote ${relative(process.cwd(), target)} ` + + `(zip ${(statSync(target).size / 1024 / 1024).toFixed(1)} MiB, ` + + `unpacked ${(unpacked / 1024 / 1024).toFixed(1)} MiB, ` + + `${Object.keys(files).length} files)`, +); diff --git a/packages/plugins/excalidraw/i18n/de.json b/packages/plugins/excalidraw/i18n/de.json new file mode 100644 index 0000000..148aa90 --- /dev/null +++ b/packages/plugins/excalidraw/i18n/de.json @@ -0,0 +1,10 @@ +{ + "empty": "Noch keine Skizze.", + "editButton": "Skizze im Vollbild bearbeiten", + "createButton": "Skizze erstellen", + "loading": "Excalidraw wird geladen …", + "save": "Speichern & Beenden", + "cancel": "Abbrechen", + "saved": "Gespeichert — „Fertig“ schließt den Bearbeiten-Modus.", + "renderHint": "Zum Bearbeiten in den Bearbeiten-Modus der Seite wechseln." +} diff --git a/packages/plugins/excalidraw/i18n/en.json b/packages/plugins/excalidraw/i18n/en.json new file mode 100644 index 0000000..75b86af --- /dev/null +++ b/packages/plugins/excalidraw/i18n/en.json @@ -0,0 +1,10 @@ +{ + "empty": "No sketch yet.", + "editButton": "Edit sketch in fullscreen", + "createButton": "Create sketch", + "loading": "Loading Excalidraw …", + "save": "Save & Exit", + "cancel": "Cancel", + "saved": "Saved — \"Done\" closes edit mode.", + "renderHint": "Switch the page to edit mode to change the sketch." +} diff --git a/packages/plugins/excalidraw/manifest.json b/packages/plugins/excalidraw/manifest.json new file mode 100644 index 0000000..8540606 --- /dev/null +++ b/packages/plugins/excalidraw/manifest.json @@ -0,0 +1,19 @@ +{ + "id": "excalidraw", + "name": "Excalidraw Whiteboard", + "version": "1.0.0", + "apiVersion": "1", + "kind": "code", + "extensionPoints": [ + { + "type": "block", + "id": "diagram", + "title": { "de": "Excalidraw-Skizze", "en": "Excalidraw sketch" } + } + ], + "permissions": ["blockData", "ui"], + "fallback": { "type": "text", "value": "[Excalidraw]" }, + "license": "MIT", + "homepage": "https://excalidraw.com", + "i18n": { "de": "i18n/de.json", "en": "i18n/en.json" } +} diff --git a/packages/plugins/excalidraw/package.json b/packages/plugins/excalidraw/package.json new file mode 100644 index 0000000..02b9a0a --- /dev/null +++ b/packages/plugins/excalidraw/package.json @@ -0,0 +1,25 @@ +{ + "name": "@dorfteich/plugin-excalidraw", + "version": "0.0.0", + "private": true, + "description": "Reference block plugin: Excalidraw hand-drawn whiteboard sketches — fullscreen editing with the bundled Excalidraw editor, inline SVG rendering", + "license": "MIT", + "scripts": { + "build": "node build.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "devDependencies": { + "@dorfteich/plugin-sdk": "workspace:*", + "@excalidraw/excalidraw": "0.18.1", + "@types/node": "^26.1.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "esbuild": "^0.24.0", + "fflate": "^0.8.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/excalidraw/src/globals.d.ts b/packages/plugins/excalidraw/src/globals.d.ts new file mode 100644 index 0000000..c0f23f5 --- /dev/null +++ b/packages/plugins/excalidraw/src/globals.d.ts @@ -0,0 +1,4 @@ +// The Excalidraw stylesheet is bundled as a string via esbuild's `.css` → text +// loader (see build.mjs) and injected at runtime; declare the module so the +// import type-checks. +declare module '@excalidraw/excalidraw/index.css'; diff --git a/packages/plugins/excalidraw/src/plugin.tsx b/packages/plugins/excalidraw/src/plugin.tsx new file mode 100644 index 0000000..76f77f3 --- /dev/null +++ b/packages/plugins/excalidraw/src/plugin.tsx @@ -0,0 +1,268 @@ +import { createPlugin, windowTransport, type RenderContext } from '@dorfteich/plugin-sdk'; +import { Excalidraw, exportToSvg, serializeAsJSON } from '@excalidraw/excalidraw'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +/** The subset of Excalidraw's imperative API this plugin uses — kept local so + * the deep types-path export (which moves between versions) is not imported. */ +interface ExcalidrawApi { + getSceneElements: () => readonly unknown[]; + getAppState: () => Record; + getFiles: () => Record; +} + +// Bundled as a string (esbuild `.css` → text loader) and injected once, since +// the sandbox frame document loads only `plugin.js` and the CSP forbids remote +// stylesheets. +import excalidrawCss from '@excalidraw/excalidraw/index.css'; + +import de from '../i18n/de.json'; +import en from '../i18n/en.json'; + +/** + * Excalidraw reference plugin: hand-drawn whiteboard sketches edited in the + * REAL Excalidraw editor, which the package bundles (npm, esbuild) together + * with its font assets — nothing is ever loaded from excalidraw.com; the + * sandbox CSP pins every request to the plugin's own version-pinned asset path + * (`EXCALIDRAW_ASSET_PATH`, set below). + * + * Block data: `{ scene, svg }` — `scene` is the Excalidraw scene as a JSON + * string (document of record), `svg` the rendered snapshot as raw markup, so + * render mode, office/PDF exports (fallback renderer) and the public read view + * can all show the sketch without running any editor code. + */ +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; +} + +interface SketchData { + /** Excalidraw scene as a serialized JSON string (serializeAsJSON). */ + scene?: string; + /** Rendered snapshot as raw SVG markup. */ + svg?: string; +} + +function dataOf(context: RenderContext): SketchData { + return context.data && typeof context.data === 'object' ? (context.data as SketchData) : {}; +} + +// Fonts, locales and font metadata load from the plugin's own asset path (the +// directory this module was served from), which the sandbox CSP allows. +// Excalidraw resolves them relative to EXCALIDRAW_ASSET_PATH (e.g. `fonts/…`); +// build.mjs copies dist/prod/{fonts,locales,data} to the ZIP root. +(window as unknown as { EXCALIDRAW_ASSET_PATH: string }).EXCALIDRAW_ASSET_PATH = new URL( + '.', + import.meta.url, +).href; + +let cssInjected = false; +function ensureCss(): void { + if (cssInjected) return; + const style = document.createElement('style'); + style.textContent = excalidrawCss; + document.head.appendChild(style); + cssInjected = true; +} + +const { host } = createPlugin({ + transport: windowTransport({ + target: { postMessage: (message) => window.parent.postMessage(message, '*') }, + source: window, + }), + onRender: (context) => renderMode(context), + onEdit: (context) => editMode(context), + onDestroy: () => closeEditor(), +}); + +function resize(): void { + void host.ui.resize(Math.max(64, document.body.scrollHeight + 16)); +} + +/** The stored snapshot as an inline drawing, or the empty-state hint. */ +function snapshotElement(data: SketchData, locale: string): HTMLElement { + if (data.svg && data.svg.trim() !== '') { + const holder = document.createElement('div'); + holder.className = 'dt-excalidraw__snapshot'; + holder.innerHTML = data.svg; + const svg = holder.querySelector('svg'); + if (svg) { + svg.style.maxWidth = '100%'; + svg.style.height = 'auto'; + } + return holder; + } + const hint = document.createElement('p'); + hint.textContent = labelFor(locale, 'empty'); + hint.style.color = '#6b7280'; + return hint; +} + +function renderMode(context: RenderContext): void { + closeEditor(); + const data = dataOf(context); + document.body.textContent = ''; + document.body.className = 'dt-excalidraw dt-excalidraw--render'; + document.body.appendChild(snapshotElement(data, context.locale)); + resize(); +} + +function editMode(context: RenderContext): void { + closeEditor(); + const data = dataOf(context); + document.body.textContent = ''; + document.body.className = 'dt-excalidraw dt-excalidraw--edit'; + + document.body.appendChild(snapshotElement(data, context.locale)); + + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = labelFor(context.locale, data.svg ? 'editButton' : 'createButton'); + button.style.cssText = 'display:block;margin:8px 0;padding:6px 12px;cursor:pointer;font:inherit;'; + button.addEventListener('click', () => void openEditor(context)); + document.body.appendChild(button); + resize(); + + // A block that has no sketch yet goes straight into the editor. + if (!data.svg && !data.scene) void openEditor(context); +} + +/** The active fullscreen editing session, if any. */ +let session: { container: HTMLDivElement; root: Root } | null = null; + +function closeEditor(): void { + if (!session) return; + const { container, root } = session; + session = null; + root.unmount(); + container.remove(); + void host.ui.exitFullscreen(); +} + +function parseScene(scene: string | undefined): { + elements: readonly unknown[]; + appState: Record; + files: Record; +} { + if (!scene) return { elements: [], appState: {}, files: {} }; + try { + const parsed = JSON.parse(scene) as { + elements?: unknown[]; + appState?: Record; + files?: Record; + }; + return { + elements: parsed.elements ?? [], + appState: parsed.appState ?? {}, + files: parsed.files ?? {}, + }; + } catch { + return { elements: [], appState: {}, files: {} }; + } +} + +async function openEditor(context: RenderContext): Promise { + if (session) return; + ensureCss(); + const data = dataOf(context); + const initial = parseScene(data.scene); + + await host.ui.enterFullscreen(); + + const container = document.createElement('div'); + container.className = 'dt-excalidraw__editor'; + container.style.cssText = + 'position:fixed;inset:0;background:#fff;display:flex;flex-direction:column;'; + document.body.appendChild(container); + + const bar = document.createElement('div'); + bar.style.cssText = + 'display:flex;gap:8px;justify-content:flex-end;padding:8px;border-bottom:1px solid #e5e7eb;background:#fff;z-index:5;'; + const cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.textContent = labelFor(context.locale, 'cancel'); + cancel.style.cssText = 'padding:6px 12px;cursor:pointer;font:inherit;'; + const save = document.createElement('button'); + save.type = 'button'; + save.textContent = labelFor(context.locale, 'save'); + save.style.cssText = 'padding:6px 12px;cursor:pointer;font:inherit;font-weight:600;'; + bar.append(cancel, save); + container.appendChild(bar); + + const canvas = document.createElement('div'); + canvas.style.cssText = 'flex:1;min-height:0;position:relative;'; + container.appendChild(canvas); + + let api: ExcalidrawApi | null = null; + const root = createRoot(canvas); + session = { container, root }; + + root.render( + createElement(Excalidraw, { + initialData: { + elements: initial.elements, + appState: initial.appState, + files: initial.files, + }, + excalidrawAPI: (instance: ExcalidrawApi) => { + api = instance; + }, + } as Parameters[0]), + ); + + cancel.addEventListener('click', () => { + closeEditor(); + finishEdit(dataOf(context), context.locale); + }); + + save.addEventListener('click', () => { + void (async () => { + if (!api) return; + const elements = api.getSceneElements(); + const appState = api.getAppState(); + const files = api.getFiles(); + const scene = serializeAsJSON( + elements as Parameters[0], + appState as Parameters[1], + files as Parameters[2], + 'local', + ); + const svgEl = await exportToSvg({ + elements, + appState: { ...appState, exportBackground: true, exportWithDarkMode: false }, + files, + } as Parameters[0]); + const svg = new XMLSerializer().serializeToString(svgEl); + await host.blockData.setData({ scene, svg } satisfies SketchData); + closeEditor(); + finishEdit({ scene, svg }, context.locale); + })(); + }); +} + +/** Redraws the inline edit surface after the fullscreen editor closed. */ +function finishEdit(data: SketchData, locale: string): void { + document.body.textContent = ''; + document.body.className = 'dt-excalidraw dt-excalidraw--edit'; + document.body.appendChild(snapshotElement(data, locale)); + + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = labelFor(locale, data.svg ? 'editButton' : 'createButton'); + button.style.cssText = 'display:block;margin:8px 0;padding:6px 12px;cursor:pointer;font:inherit;'; + button.addEventListener( + 'click', + () => void openEditor({ extensionPointId: 'diagram', locale, data }), + ); + document.body.appendChild(button); + + if (data.svg) { + const note = document.createElement('p'); + note.textContent = labelFor(locale, 'saved'); + note.style.cssText = 'color:#6b7280;font-size:13px;'; + document.body.appendChild(note); + } + resize(); +} diff --git a/packages/plugins/excalidraw/tsconfig.json b/packages/plugins/excalidraw/tsconfig.json new file mode 100644 index 0000000..7ff813a --- /dev/null +++ b/packages/plugins/excalidraw/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "noEmit": true, + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src", "*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aeb390f..d04f0ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@floating-ui/react-dom': 2.1.2 + importers: .: @@ -410,6 +413,42 @@ 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/excalidraw: + devDependencies: + '@dorfteich/plugin-sdk': + specifier: workspace:* + version: link:../../plugin-sdk + '@excalidraw/excalidraw': + specifier: 0.18.1 + version: 0.18.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/node': + specifier: ^26.1.0 + version: 26.1.0 + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.17) + esbuild: + specifier: ^0.24.0 + version: 0.24.2 + fflate: + specifier: ^0.8.2 + version: 0.8.3 + react: + specifier: ^19.0.0 + version: 19.2.7 + react-dom: + specifier: ^19.0.0 + version: 19.2.7(react@19.2.7) + 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/mermaid: devDependencies: '@dorfteich/plugin-sdk': @@ -1086,12 +1125,30 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@braintree/sanitize-url@6.0.2': + resolution: {integrity: sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==} + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@chevrotain/cst-dts-gen@11.0.3': + resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} + + '@chevrotain/gast@11.0.3': + resolution: {integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==} + + '@chevrotain/regexp-to-ast@11.0.3': + resolution: {integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==} + + '@chevrotain/types@11.0.3': + resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==} + '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@chevrotain/utils@11.0.3': + resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -1783,12 +1840,37 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@excalidraw/excalidraw@0.18.1': + resolution: {integrity: sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw==} + peerDependencies: + react: ^17.0.2 || ^18.2.0 || ^19.0.0 + react-dom: ^17.0.2 || ^18.2.0 || ^19.0.0 + + '@excalidraw/laser-pointer@1.3.1': + resolution: {integrity: sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==} + + '@excalidraw/markdown-to-text@0.1.2': + resolution: {integrity: sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==} + + '@excalidraw/mermaid-to-excalidraw@2.2.2': + resolution: {integrity: sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==} + + '@excalidraw/random-username@1.1.0': + resolution: {integrity: sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==} + engines: {node: '>=10'} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} '@floating-ui/dom@1.7.6': resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/react-dom@2.1.2': + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} @@ -2022,6 +2104,9 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@mermaid-js/parser@0.6.3': + resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} @@ -2229,6 +2314,288 @@ packages: '@prisma/get-platform@6.19.3': resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + '@radix-ui/primitive@1.0.0': + resolution: {integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==} + + '@radix-ui/primitive@1.1.1': + resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} + + '@radix-ui/react-arrow@1.1.2': + resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.0.1': + resolution: {integrity: sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-compose-refs@1.0.0': + resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-compose-refs@1.1.1': + resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.0.0': + resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-context@1.1.1': + resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.0.0': + resolution: {integrity: sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-dismissable-layer@1.1.5': + resolution: {integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.1': + resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.2': + resolution: {integrity: sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.0.0': + resolution: {integrity: sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-id@1.1.0': + resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-popover@1.1.6': + resolution: {integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.2': + resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.4': + resolution: {integrity: sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.0.0': + resolution: {integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-presence@1.1.2': + resolution: {integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@1.0.1': + resolution: {integrity: sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-primitive@2.0.2': + resolution: {integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.0.2': + resolution: {integrity: sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-slot@1.0.1': + resolution: {integrity: sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-slot@1.1.2': + resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.0.2': + resolution: {integrity: sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-callback-ref@1.0.0': + resolution: {integrity: sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-callback-ref@1.1.0': + resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.0.0': + resolution: {integrity: sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-controllable-state@1.1.0': + resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.0': + resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.0.0': + resolution: {integrity: sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-layout-effect@1.1.0': + resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.0': + resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.0': + resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/rect@1.1.0': + resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -3051,6 +3418,10 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} @@ -3069,6 +3440,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -3187,6 +3562,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -3204,6 +3583,13 @@ packages: resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-fs-access@0.29.1: + resolution: {integrity: sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==} + browserslist@4.28.4: resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -3267,6 +3653,9 @@ packages: caniuse-lite@1.0.30001800: resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} + canvas-roundrect-polyfill@0.0.1: + resolution: {integrity: sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -3282,6 +3671,18 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.0.3: + resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -3316,6 +3717,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@1.1.1: + resolution: {integrity: sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -3439,6 +3844,10 @@ packages: typescript: optional: true + crc-32@0.3.0: + resolution: {integrity: sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==} + engines: {node: '>=0.8'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -3453,6 +3862,11 @@ packages: engines: {node: '>=20'} hasBin: true + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3709,6 +4123,9 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} @@ -3813,6 +4230,10 @@ packages: es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + es6-promise-pool@2.5.0: + resolution: {integrity: sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==} + engines: {node: '>=0.10.0'} + esbuild@0.24.2: resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} engines: {node: '>=18'} @@ -4011,6 +4432,10 @@ packages: filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -4056,6 +4481,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + fractional-indexing@4.0.0: resolution: {integrity: sha512-Nr2P1Yyaj2sy1Qdt/wI3GcByxrUdbSKg5+cGvw8f18hT2SkQt6V72iOjBpk7IO3UkzBsdlGRs2a4z6dLrJprgw==} engines: {node: ^14.13.1 || >=16.0.0} @@ -4095,6 +4524,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + fuzzy@0.1.3: + resolution: {integrity: sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==} + engines: {node: '>= 0.6.0'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -4111,6 +4544,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-own-enumerable-property-symbols@3.0.2: resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} @@ -4126,6 +4563,10 @@ packages: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -4156,6 +4597,9 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + glur@1.1.2: + resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -4252,6 +4696,12 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + image-blob-reduce@3.0.1: + resolution: {integrity: sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==} + + immutable@4.3.9: + resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -4300,6 +4750,10 @@ packages: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -4363,6 +4817,10 @@ packages: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-obj@1.0.1: resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} engines: {node: '>=0.10.0'} @@ -4460,6 +4918,24 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jotai-scope@0.7.2: + resolution: {integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==} + peerDependencies: + jotai: '>=2.9.2' + react: '>=17.0.0' + + jotai@2.11.0: + resolution: {integrity: sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -4535,6 +5011,10 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + langium@3.3.1: + resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} + engines: {node: '>=16.0.0'} + layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -4584,6 +5064,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} @@ -4596,6 +5079,9 @@ packages: lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -4726,6 +5212,9 @@ packages: resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} engines: {node: '>= 10.16.0'} + multimath@2.0.0: + resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -4738,6 +5227,16 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.3: + resolution: {integrity: sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@4.0.2: + resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==} + engines: {node: ^14 || ^16 || >=18} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -4828,6 +5327,9 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + open-color@1.9.1: + resolution: {integrity: sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -4857,6 +5359,9 @@ packages: package-manager-detector@1.7.0: resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==} + pako@2.0.3: + resolution: {integrity: sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4923,6 +5428,9 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + perfect-freehand@1.2.0: + resolution: {integrity: sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==} + pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -4957,9 +5465,16 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + pica@7.1.1: + resolution: {integrity: sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -5016,9 +5531,21 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + png-chunk-text@1.0.0: + resolution: {integrity: sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==} + + png-chunks-encode@1.0.0: + resolution: {integrity: sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==} + + png-chunks-extract@1.0.0: + resolution: {integrity: sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + points-on-curve@1.0.1: + resolution: {integrity: sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==} + points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} @@ -5161,6 +5688,9 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pwacompat@2.0.17: + resolution: {integrity: sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -5210,6 +5740,26 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-router-dom@7.18.1: resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} engines: {node: '>=20.0.0'} @@ -5227,6 +5777,16 @@ packages: react-dom: optional: true + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -5245,6 +5805,10 @@ packages: readdir-glob@1.1.3: resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -5314,6 +5878,9 @@ packages: rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + roughjs@4.6.4: + resolution: {integrity: sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==} + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -5358,6 +5925,11 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sass@1.51.0: + resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==} + engines: {node: '>=12.0.0'} + hasBin: true + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -5449,6 +6021,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sliced@1.0.1: + resolution: {integrity: sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==} + deprecated: Unsupported + smob@1.6.2: resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} engines: {node: '>=20.0.0'} @@ -5717,6 +6293,10 @@ packages: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -5788,6 +6368,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-rat@0.1.2: + resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -5909,6 +6492,26 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -6054,6 +6657,26 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.0.8: + resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -6096,6 +6719,9 @@ packages: webpack-cli: optional: true + webworkify@1.5.0: + resolution: {integrity: sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==} + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -6285,6 +6911,21 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + snapshots: '@angular-devkit/core@19.2.24(chokidar@4.0.3)': @@ -7035,10 +7676,29 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@braintree/sanitize-url@6.0.2': {} + '@braintree/sanitize-url@7.1.2': {} + '@chevrotain/cst-dts-gen@11.0.3': + dependencies: + '@chevrotain/gast': 11.0.3 + '@chevrotain/types': 11.0.3 + lodash-es: 4.17.21 + + '@chevrotain/gast@11.0.3': + dependencies: + '@chevrotain/types': 11.0.3 + lodash-es: 4.17.21 + + '@chevrotain/regexp-to-ast@11.0.3': {} + + '@chevrotain/types@11.0.3': {} + '@chevrotain/types@11.1.2': {} + '@chevrotain/utils@11.0.3': {} + '@colors/colors@1.5.0': optional: true @@ -7419,6 +8079,59 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@excalidraw/excalidraw@0.18.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@braintree/sanitize-url': 6.0.2 + '@excalidraw/laser-pointer': 1.3.1 + '@excalidraw/mermaid-to-excalidraw': 2.2.2 + '@excalidraw/random-username': 1.1.0 + '@radix-ui/react-popover': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': 1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + browser-fs-access: 0.29.1 + canvas-roundrect-polyfill: 0.0.1 + clsx: 1.1.1 + cross-env: 7.0.3 + es6-promise-pool: 2.5.0 + fractional-indexing: 3.2.0 + fuzzy: 0.1.3 + image-blob-reduce: 3.0.1 + jotai: 2.11.0(@types/react@19.2.17)(react@19.2.7) + jotai-scope: 0.7.2(jotai@2.11.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + lodash.debounce: 4.0.8 + lodash.throttle: 4.1.1 + nanoid: 3.3.3 + open-color: 1.9.1 + pako: 2.0.3 + perfect-freehand: 1.2.0 + pica: 7.1.1 + png-chunk-text: 1.0.0 + png-chunks-encode: 1.0.0 + png-chunks-extract: 1.0.0 + points-on-curve: 1.0.1 + pwacompat: 2.0.17 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + roughjs: 4.6.4 + sass: 1.51.0 + tunnel-rat: 0.1.2(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - immer + + '@excalidraw/laser-pointer@1.3.1': {} + + '@excalidraw/markdown-to-text@0.1.2': {} + + '@excalidraw/mermaid-to-excalidraw@2.2.2': + dependencies: + '@excalidraw/markdown-to-text': 0.1.2 + '@mermaid-js/parser': 0.6.3 + mermaid: 11.16.0 + nanoid: 4.0.2 + + '@excalidraw/random-username@1.1.0': {} + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -7428,6 +8141,12 @@ snapshots: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 + '@floating-ui/react-dom@2.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@floating-ui/utils@0.2.11': {} '@hocuspocus/common@4.3.0': @@ -7666,6 +8385,10 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@mermaid-js/parser@0.6.3': + dependencies: + langium: 3.3.1 + '@mermaid-js/parser@1.2.0': dependencies: '@chevrotain/types': 11.1.2 @@ -7883,6 +8606,286 @@ snapshots: dependencies: '@prisma/debug': 6.19.3 + '@radix-ui/primitive@1.0.0': + dependencies: + '@babel/runtime': 7.29.7 + + '@radix-ui/primitive@1.1.1': {} + + '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collection@1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@radix-ui/react-compose-refs': 1.0.0(react@19.2.7) + '@radix-ui/react-context': 1.0.0(react@19.2.7) + '@radix-ui/react-primitive': 1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.0.1(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@radix-ui/react-compose-refs@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.7 + + '@radix-ui/react-compose-refs@1.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.7 + + '@radix-ui/react-context@1.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-direction@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.7 + + '@radix-ui/react-dismissable-layer@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.7) + react: 19.2.7 + + '@radix-ui/react-id@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-popover@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.2.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@radix-ui/react-compose-refs': 1.0.0(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@radix-ui/react-presence@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@radix-ui/react-slot': 1.0.1(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@radix-ui/react-primitive@2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-roving-focus@1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@radix-ui/primitive': 1.0.0 + '@radix-ui/react-collection': 1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.0.0(react@19.2.7) + '@radix-ui/react-context': 1.0.0(react@19.2.7) + '@radix-ui/react-direction': 1.0.0(react@19.2.7) + '@radix-ui/react-id': 1.0.0(react@19.2.7) + '@radix-ui/react-primitive': 1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.0.0(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@radix-ui/react-slot@1.0.1(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@radix-ui/react-compose-refs': 1.0.0(react@19.2.7) + react: 19.2.7 + + '@radix-ui/react-slot@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-tabs@1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@radix-ui/primitive': 1.0.0 + '@radix-ui/react-context': 1.0.0(react@19.2.7) + '@radix-ui/react-direction': 1.0.0(react@19.2.7) + '@radix-ui/react-id': 1.0.0(react@19.2.7) + '@radix-ui/react-presence': 1.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.0.0(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@radix-ui/react-use-callback-ref@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.7 + + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.7) + react: 19.2.7 + + '@radix-ui/react-use-controllable-state@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-escape-keydown@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.7 + + '@radix-ui/react-use-layout-effect@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.0 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/rect@1.1.0': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@4.62.2)': @@ -8748,6 +9751,11 @@ snapshots: any-promise@1.3.0: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + append-field@1.0.0: {} archiver-utils@5.0.2: @@ -8783,6 +9791,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -8885,6 +9897,8 @@ snapshots: baseline-browser-mapping@2.10.41: {} + binary-extensions@2.3.0: {} + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -8918,6 +9932,12 @@ snapshots: dependencies: balanced-match: 4.0.4 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-fs-access@0.29.1: {} + browserslist@4.28.4: dependencies: baseline-browser-mapping: 2.10.41 @@ -8989,6 +10009,8 @@ snapshots: caniuse-lite@1.0.30001800: {} + canvas-roundrect-polyfill@0.0.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -9006,6 +10028,32 @@ snapshots: check-error@2.1.3: {} + chevrotain-allstar@0.3.1(chevrotain@11.0.3): + dependencies: + chevrotain: 11.0.3 + lodash-es: 4.18.1 + + chevrotain@11.0.3: + dependencies: + '@chevrotain/cst-dts-gen': 11.0.3 + '@chevrotain/gast': 11.0.3 + '@chevrotain/regexp-to-ast': 11.0.3 + '@chevrotain/types': 11.0.3 + '@chevrotain/utils': 11.0.3 + lodash-es: 4.17.21 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -9034,6 +10082,8 @@ snapshots: clone@1.0.4: {} + clsx@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -9137,6 +10187,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + crc-32@0.3.0: {} + crc-32@1.2.2: {} crc32-stream@6.0.0: @@ -9149,6 +10201,10 @@ snapshots: '@epic-web/invariant': 1.0.0 cross-spawn: 7.0.6 + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -9419,6 +10475,8 @@ snapshots: destr@2.0.5: {} + detect-node-es@1.1.0: {} + dezalgo@1.0.4: dependencies: asap: 2.0.6 @@ -9572,6 +10630,8 @@ snapshots: es-toolkit@1.49.0: {} + es6-promise-pool@2.5.0: {} + esbuild@0.24.2: optionalDependencies: '@esbuild/aix-ppc64': 0.24.2 @@ -9887,6 +10947,10 @@ snapshots: dependencies: minimatch: 5.1.9 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -9958,6 +11022,8 @@ snapshots: forwarded@0.2.0: {} + fractional-indexing@3.2.0: {} + fractional-indexing@4.0.0: {} fresh@2.0.0: {} @@ -9999,6 +11065,8 @@ snapshots: functions-have-names@1.2.3: {} + fuzzy@0.1.3: {} + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -10018,6 +11086,8 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-own-enumerable-property-symbols@3.0.2: {} get-proto@1.0.1: @@ -10040,6 +11110,10 @@ snapshots: nypm: 0.6.8 pathe: 2.0.3 + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -10077,6 +11151,8 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + glur@1.1.2: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -10163,6 +11239,12 @@ snapshots: ignore@7.0.5: {} + image-blob-reduce@3.0.1: + dependencies: + pica: 7.1.1 + + immutable@4.3.9: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -10208,6 +11290,10 @@ snapshots: dependencies: has-bigints: 1.1.0 + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 @@ -10267,6 +11353,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-number@7.0.0: {} + is-obj@1.0.1: {} is-potential-custom-element-name@1.0.1: {} @@ -10354,6 +11442,16 @@ snapshots: jose@6.2.3: {} + jotai-scope@0.7.2(jotai@2.11.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + jotai: 2.11.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + + jotai@2.11.0(@types/react@19.2.17)(react@19.2.7): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -10429,6 +11527,14 @@ snapshots: kleur@4.1.5: {} + langium@3.3.1: + dependencies: + chevrotain: 11.0.3 + chevrotain-allstar: 0.3.1(chevrotain@11.0.3) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + layout-base@1.0.2: {} layout-base@2.0.1: {} @@ -10466,6 +11572,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.17.21: {} + lodash-es@4.18.1: {} lodash.debounce@4.0.8: {} @@ -10474,6 +11582,8 @@ snapshots: lodash.sortby@4.7.0: {} + lodash.throttle@4.1.1: {} + lodash@4.18.1: {} log-symbols@4.1.0: @@ -10608,6 +11718,11 @@ snapshots: concat-stream: 2.0.0 type-is: 1.6.18 + multimath@2.0.0: + dependencies: + glur: 1.1.2 + object-assign: 4.1.1 + mute-stream@2.0.0: {} mz@2.7.0: @@ -10618,6 +11733,10 @@ snapshots: nanoid@3.3.15: {} + nanoid@3.3.3: {} + + nanoid@4.0.2: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} @@ -10688,6 +11807,8 @@ snapshots: dependencies: mimic-fn: 2.1.0 + open-color@1.9.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -10729,6 +11850,8 @@ snapshots: package-manager-detector@1.7.0: {} + pako@2.0.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -10787,6 +11910,8 @@ snapshots: perfect-debounce@1.0.0: {} + perfect-freehand@1.2.0: {} + pg-cloudflare@1.4.0: optional: true @@ -10822,8 +11947,18 @@ snapshots: dependencies: split2: 4.2.0 + pica@7.1.1: + dependencies: + glur: 1.1.2 + inherits: 2.0.4 + multimath: 2.0.0 + object-assign: 4.1.1 + webworkify: 1.5.0 + picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.4: {} picomatch@4.0.5: {} @@ -10901,8 +12036,21 @@ snapshots: pluralize@8.0.0: {} + png-chunk-text@1.0.0: {} + + png-chunks-encode@1.0.0: + dependencies: + crc-32: 0.3.0 + sliced: 1.0.1 + + png-chunks-extract@1.0.0: + dependencies: + crc-32: 0.3.0 + points-on-curve@0.2.0: {} + points-on-curve@1.0.1: {} + points-on-path@0.2.1: dependencies: path-data-parser: 0.1.0 @@ -11053,6 +12201,8 @@ snapshots: pure-rand@6.1.0: {} + pwacompat@2.0.17: {} + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -11096,6 +12246,25 @@ snapshots: react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: react: 19.2.7 @@ -11110,6 +12279,14 @@ snapshots: optionalDependencies: react-dom: 19.2.7(react@19.2.7) + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + react@19.2.7: {} readable-stream@2.3.8: @@ -11140,6 +12317,10 @@ snapshots: dependencies: minimatch: 5.1.9 + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + readdirp@4.1.2: {} real-require@0.2.0: {} @@ -11240,6 +12421,13 @@ snapshots: rope-sequence@1.3.4: {} + roughjs@4.6.4: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -11296,6 +12484,12 @@ snapshots: safer-buffer@2.1.2: {} + sass@1.51.0: + dependencies: + chokidar: 3.6.0 + immutable: 4.3.9 + source-map-js: 1.2.1 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -11414,6 +12608,8 @@ snapshots: signal-exit@4.1.0: {} + sliced@1.0.1: {} + smob@1.6.2: {} sonic-boom@4.2.1: @@ -11686,6 +12882,10 @@ snapshots: dependencies: tldts-core: 6.1.86 + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + toidentifier@1.0.1: {} token-types@6.1.2: @@ -11766,6 +12966,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-rat@0.1.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + zustand: 4.5.7(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - immer + - react + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -11899,6 +13107,21 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + use-sync-external-store@1.6.0(react@19.2.7): dependencies: react: 19.2.7 @@ -12015,6 +13238,23 @@ snapshots: void-elements@3.1.0: {} + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.0.8: {} + w3c-keyname@2.2.8: {} w3c-xmlserializer@5.0.0: @@ -12079,6 +13319,8 @@ snapshots: - postcss - uglify-js + webworkify@1.5.0: {} + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -12333,3 +13575,10 @@ snapshots: zod@3.25.76: {} zod@4.4.3: {} + + zustand@4.5.7(@types/react@19.2.17)(react@19.2.7): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b51675f..836fe2c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,3 +12,11 @@ allowBuilds: argon2: true esbuild: true prisma: true +# Excalidraw 0.18.1 (excalidraw plugin, issue #136) transitively pulls +# @floating-ui/react-dom@2.1.9, which requires @floating-ui/dom@^1.8.0 — a +# version not published on the registry (latest 1.7.6), breaking `pnpm install` +# repo-wide. Pin react-dom to 2.1.2, which needs @floating-ui/dom@^1.0.0. Only +# the excalidraw plugin pulls @floating-ui at all; drop this once upstream +# @floating-ui/dom@1.8.0 is published. +overrides: + '@floating-ui/react-dom': '2.1.2' -- 2.45.2 From d5b895ada2bc6797144457cc539ef59995785e1c Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 19 Jul 2026 06:01:45 +0200 Subject: [PATCH 09/10] #135 Fix: /read-Route deklariert Zugriffsregel explizit (@AuthenticatedOnly) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit route-permissions.e2e.db.test.ts (#52) verlangt, dass JEDE Route ihre Zugriffsregel explizit deklariert (PERMISSION_KEY, @Public oder SiteAdminGuard). Der neue GET /read/:pond/:slug hatte keinen Decorator (verließ sich auf den Default-Guard) → Coverage-Test rot in CI. @AuthenticatedOnly() ergänzt (Session erforderlich; per-Page-Recht prüft weiterhin der Service via resolve→canAccessPage→404). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/api/src/public/read-content.controller.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/api/src/public/read-content.controller.ts b/apps/api/src/public/read-content.controller.ts index f827af6..902417d 100644 --- a/apps/api/src/public/read-content.controller.ts +++ b/apps/api/src/public/read-content.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Param, Req } from '@nestjs/common'; import { AuthedRequest } from '../auth/auth.guard'; +import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { PublicPageContent, PublicService } from './public.service'; /** @@ -16,7 +17,10 @@ import { PublicPageContent, PublicService } from './public.service'; export class ReadContentController { constructor(private readonly publicPages: PublicService) {} + // Session required (explicit access rule, issue #52); per-page read + // permission is enforced in the service (resolve → canAccessPage → 404). @Get(':pondSlug/:pageSlug') + @AuthenticatedOnly() async content( @Param('pondSlug') pondSlug: string, @Param('pageSlug') pageSlug: string, -- 2.45.2 From 5255cdce06bf632b47b03b9679d4d39ff05a208e Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 19 Jul 2026 06:12:55 +0200 Subject: [PATCH 10/10] Fix latenten Flake in links.service.db.test (Phantom-Sortierung) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pondGraph-Test sortierte die EMPFANGENEN Phantom-Slugs, verglich aber gegen ein UNsortiertes Literal. Da beide Slugs (ghost-, ghost-secret-) den Zufalls-Suffix teilen, kippt ihre Sortierreihen- folge auf ~1/5 der Suffixe (wenn sfx[0] > 's') → nicht-deterministischer Fehlschlag. In CI-Lauf 372 traf es zu (Suffix „vpxwe…"). Fix: beide Seiten sortieren. Vorbestehender Bug, unabhängig von M17–M19. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/api/src/links/links.service.db.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/api/src/links/links.service.db.test.ts b/apps/api/src/links/links.service.db.test.ts index cc77b21..5114db0 100644 --- a/apps/api/src/links/links.service.db.test.ts +++ b/apps/api/src/links/links.service.db.test.ts @@ -277,10 +277,12 @@ describe.skipIf(!hasTestDb)('LinksService.pondGraph (db, issue #111)', () => { { from: a, to: secret }, ].sort((x, y) => x.to.localeCompare(y.to)), ); - expect(graph.phantoms.map((p) => p.targetSlug).sort()).toEqual([ - `ghost-${suffix}`, - `ghost-secret-${suffix}`, - ]); + // Sort BOTH sides: phantom order is not guaranteed, and the two slugs share + // the random suffix so their sort order flips on ~1/5 of suffixes (the + // expected array was previously left unsorted — a latent flake). + expect(graph.phantoms.map((p) => p.targetSlug).sort()).toEqual( + [`ghost-${suffix}`, `ghost-secret-${suffix}`].sort(), + ); }); it('slices every collection to the reader — no hidden page leaks anywhere', async () => { -- 2.45.2