From fdc79bcd27659013ece7ae3d4b59b1efe3e7f472 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 4 Sep 2026 16:05:41 +0200 Subject: [PATCH 1/5] feat(editor): browse and install npm packages from the Dependencies panel The panel could only list what package.json already declared, so adding a package meant knowing its exact name and typing it. It now searches the registry, shows a package page with README, versions, dependencies and OSV advisories, and installs a chosen version. The browser never talks to a registry. A server-side proxy on the site.read floor owns the host, the credentials and the caching, so NPM_REGISTRY_URL points browsing, resolving and bun install at one registry. --- .env.example | 5 + CLAUDE.md | 2 + docs/README.md | 2 + docs/deployment/README.md | 1 + docs/deployment/docker-image.md | 1 + docs/e2e/README.md | 2 +- docs/e2e/feature-matrix.md | 4 +- docs/e2e/feature-validation.tsv | 2 +- docs/editor.md | 2 +- docs/features/dependencies.md | 108 ++++ docs/features/media.md | 2 +- docs/features/site-shell.md | 6 +- docs/reference/architecture-tests.md | 2 +- docs/server.md | 5 +- server/ai/drivers/http/chatCompletions.ts | 3 +- server/config.ts | 5 + .../handlers/cms/__tests__/registry.test.ts | 120 ++++ server/handlers/cms/index.ts | 4 + server/handlers/cms/registry.ts | 155 +++++ server/index.ts | 2 + server/publish/runtime/dependencyCache.ts | 5 + server/publish/runtime/dependencyResolver.ts | 96 +-- server/publish/runtime/packageImportmap.ts | 67 +- server/registry/__tests__/cache.test.ts | 68 ++ server/registry/__tests__/client.test.ts | 409 ++++++++++++ server/registry/cache.ts | 93 +++ server/registry/client.ts | 521 +++++++++++++++ server/registry/config.ts | 65 ++ server/registry/upstream.ts | 93 +++ server/repositories/site.ts | 5 +- .../button-primitive-usage.test.ts | 4 +- .../no-core-barrel-deep-imports.test.ts | 3 + .../no-native-title-tooltips.test.ts | 7 - .../core/markdownDocumentSanitize.test.ts | 78 +++ .../layout/editorLayoutPersistence.test.tsx | 2 +- .../panels/dependenciesPanel.test.tsx | 458 +++++++++++++ .../panels/depsSectionRuntime.test.tsx | 257 -------- src/__tests__/server/serverConfig.test.ts | 5 +- src/admin/access.ts | 22 +- .../{pages/media/utils => lib}/formatBytes.ts | 10 +- .../MediaCanvas/MediaCanvasItems.tsx | 2 +- .../MediaViewerWindow/MediaViewerWindow.tsx | 2 +- .../ReplaceFileDialog/ReplaceFileDialog.tsx | 2 +- .../UploadQueueWindow/UploadQueueWindow.tsx | 2 +- .../site/hooks/useAutoResolveDependencies.ts | 17 +- .../DependenciesPanel/DependenciesPanel.tsx | 5 +- .../DependenciesPanel/DepsSection.module.css | 238 ------- .../panels/DependenciesPanel/DepsSection.tsx | 608 ------------------ .../DependenciesPanel/HomeView.module.css | 97 +++ .../panels/DependenciesPanel/HomeView.tsx | 207 ++++++ .../InstallControl.module.css | 62 ++ .../DependenciesPanel/InstallControl.tsx | 156 +++++ .../PackageDetailView.module.css | 247 +++++++ .../DependenciesPanel/PackageDetailView.tsx | 271 ++++++++ .../PackageReadme.module.css | 126 ++++ .../DependenciesPanel/PackageReadme.tsx | 24 + .../DependenciesPanel/PackageTiles.module.css | 375 +++++++++++ .../panels/DependenciesPanel/PackageTiles.tsx | 204 ++++++ .../RegistryPanel.module.css | 66 ++ .../DependenciesPanel/RegistryPanel.tsx | 161 +++++ .../DependenciesPanel/ResultsView.module.css | 89 +++ .../panels/DependenciesPanel/ResultsView.tsx | 156 +++++ .../site/panels/DependenciesPanel/curated.ts | 56 ++ .../site/panels/DependenciesPanel/format.ts | 28 + .../panels/DependenciesPanel/lockStatus.ts | 59 -- .../DependenciesPanel/packageVersions.ts | 21 + .../panels/DependenciesPanel/readmeHtml.ts | 12 + .../panels/DependenciesPanel/runtimeIssues.ts | 67 ++ .../site/panels/DependenciesPanel/tint.ts | 11 + .../useInstalledDependencies.ts | 97 +++ .../DependenciesPanel/useRegistryData.ts | 209 ++++++ .../MediaExplorerPanel/mediaExplorerUtils.ts | 2 +- .../SiteExplorerPanel/useSiteExplorerDnd.ts | 5 +- .../pages/site/store/slices/sitePanelSlice.ts | 15 +- src/core/module-engine/dependencies.ts | 9 +- src/core/persistence/cmsRegistry.ts | 124 ++++ src/core/persistence/index.ts | 8 + src/core/registry/__tests__/esmEntry.test.ts | 71 ++ src/core/registry/description.ts | 18 + src/core/registry/esmEntry.ts | 83 +++ src/core/registry/index.ts | 26 + src/core/registry/schemas.ts | 130 ++++ src/core/sanitize.ts | 90 ++- src/core/site-dependencies/lockStatus.ts | 27 + src/core/site-dependencies/manifest.ts | 26 + src/core/site-dependencies/packageNames.ts | 7 + src/core/site-runtime/runtimeConfig.ts | 5 +- src/core/utils/isRecord.ts | 4 + src/core/utils/typeboxHelpers.ts | 25 + src/core/utils/urlValidation.ts | 5 + tests/e2e/runtime-dependencies.e2e.ts | 36 ++ 91 files changed, 5742 insertions(+), 1352 deletions(-) create mode 100644 docs/features/dependencies.md create mode 100644 server/handlers/cms/__tests__/registry.test.ts create mode 100644 server/handlers/cms/registry.ts create mode 100644 server/registry/__tests__/cache.test.ts create mode 100644 server/registry/__tests__/client.test.ts create mode 100644 server/registry/cache.ts create mode 100644 server/registry/client.ts create mode 100644 server/registry/config.ts create mode 100644 server/registry/upstream.ts create mode 100644 src/__tests__/core/markdownDocumentSanitize.test.ts create mode 100644 src/__tests__/panels/dependenciesPanel.test.tsx delete mode 100644 src/__tests__/panels/depsSectionRuntime.test.tsx rename src/admin/{pages/media/utils => lib}/formatBytes.ts (56%) delete mode 100644 src/admin/pages/site/panels/DependenciesPanel/DepsSection.module.css delete mode 100644 src/admin/pages/site/panels/DependenciesPanel/DepsSection.tsx create mode 100644 src/admin/pages/site/panels/DependenciesPanel/HomeView.module.css create mode 100644 src/admin/pages/site/panels/DependenciesPanel/HomeView.tsx create mode 100644 src/admin/pages/site/panels/DependenciesPanel/InstallControl.module.css create mode 100644 src/admin/pages/site/panels/DependenciesPanel/InstallControl.tsx create mode 100644 src/admin/pages/site/panels/DependenciesPanel/PackageDetailView.module.css create mode 100644 src/admin/pages/site/panels/DependenciesPanel/PackageDetailView.tsx create mode 100644 src/admin/pages/site/panels/DependenciesPanel/PackageReadme.module.css create mode 100644 src/admin/pages/site/panels/DependenciesPanel/PackageReadme.tsx create mode 100644 src/admin/pages/site/panels/DependenciesPanel/PackageTiles.module.css create mode 100644 src/admin/pages/site/panels/DependenciesPanel/PackageTiles.tsx create mode 100644 src/admin/pages/site/panels/DependenciesPanel/RegistryPanel.module.css create mode 100644 src/admin/pages/site/panels/DependenciesPanel/RegistryPanel.tsx create mode 100644 src/admin/pages/site/panels/DependenciesPanel/ResultsView.module.css create mode 100644 src/admin/pages/site/panels/DependenciesPanel/ResultsView.tsx create mode 100644 src/admin/pages/site/panels/DependenciesPanel/curated.ts create mode 100644 src/admin/pages/site/panels/DependenciesPanel/format.ts delete mode 100644 src/admin/pages/site/panels/DependenciesPanel/lockStatus.ts create mode 100644 src/admin/pages/site/panels/DependenciesPanel/packageVersions.ts create mode 100644 src/admin/pages/site/panels/DependenciesPanel/readmeHtml.ts create mode 100644 src/admin/pages/site/panels/DependenciesPanel/runtimeIssues.ts create mode 100644 src/admin/pages/site/panels/DependenciesPanel/tint.ts create mode 100644 src/admin/pages/site/panels/DependenciesPanel/useInstalledDependencies.ts create mode 100644 src/admin/pages/site/panels/DependenciesPanel/useRegistryData.ts create mode 100644 src/core/persistence/cmsRegistry.ts create mode 100644 src/core/registry/__tests__/esmEntry.test.ts create mode 100644 src/core/registry/description.ts create mode 100644 src/core/registry/esmEntry.ts create mode 100644 src/core/registry/index.ts create mode 100644 src/core/registry/schemas.ts create mode 100644 src/core/site-dependencies/lockStatus.ts create mode 100644 src/core/utils/isRecord.ts diff --git a/.env.example b/.env.example index 33fe0d113..5e7316fbc 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,11 @@ PORT=3001 # # DATABASE_URL=sqlite:./.tmp/dev.db +# ─── npm registry ──────────────────────────────────────────────────────────── +# Registry the Dependencies panel, the dependency resolver and runtime installs +# use. Leave unset for the public registry. +# NPM_REGISTRY_URL=https://registry.npmjs.org + # ─── Filesystem paths ──────────────────────────────────────────────────────── UPLOADS_DIR=./uploads STATIC_DIR=./dist diff --git a/CLAUDE.md b/CLAUDE.md index 558cd8789..e3f0befff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -273,6 +273,8 @@ Deep imports into these engine modules are enforced by `src/__tests__/architectu - `@core/framework` — the framework engine (color, typography, spacing CSS generation) - `@core/framework-schema` — pure leaf: TypeBox schemas + derived types for persisted framework token settings; no dependency on the engine or page-tree - `@core/fonts` +- `@core/collab` +- `@core/registry` — TypeBox shapes for the npm registry proxy plus the ESM-entry preflight shared by the importmap builder and the Dependencies panel Note: `@core/framework-schema` is a dependency of both `@core/page-tree` (for `FrameworkSettingsSchema` and `GeneratedClassMetadataSchema`) and `@core/framework` (for the persisted data shapes). This arrangement keeps the module graph one-directional — the engine depends on the schema leaf, not on the page tree. Any other module barrel is still a convention without a gate; treat deep imports in those as drift and migrate them to the barrel as part of whatever change you're making. diff --git a/docs/README.md b/docs/README.md index 2b1606817..797f146e3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ docs/ │ ├── content-workspace.md ← Content workspace: collections, entries, body editor │ ├── auth-and-access.md ← sessions, MFA, capabilities, roles │ ├── site-shell.md ← site config (breakpoints, classes, files, deps) +│ ├── dependencies.md ← Dependencies panel: registry proxy, install/remove, lock │ ├── modules.md ← module engine + first-party blocks │ ├── data-workspace.md ← Data workspace: table schema + field management UI │ ├── dashboard.md ← Dashboard workspace + widget registry @@ -144,6 +145,7 @@ Three categories, three voices: | [features/data-workspace.md](features/data-workspace.md) | Data workspace UI: DataInspector, field management, DataGrid | | [features/auth-and-access.md](features/auth-and-access.md) | Sessions, MFA, step-up, lockout, CSRF, capabilities | | [features/site-shell.md](features/site-shell.md) | The persisted site config (breakpoints, classes, files, deps) | +| [features/dependencies.md](features/dependencies.md) | Dependencies panel: npm registry proxy, browse, install, remove, lock | | [features/modules.md](features/modules.md) | Module engine, defining first-party blocks | | [features/dashboard.md](features/dashboard.md) | Dashboard workspace, widgets, grid, customize mode | | [features/spotlight.md](features/spotlight.md) | Cmd+K command palette | diff --git a/docs/deployment/README.md b/docs/deployment/README.md index 998ce0a5a..2bf1b73bd 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -32,6 +32,7 @@ STATIC_DIR built admin SPA directory; /app/dist in the Docker image INSTATIC_SECRET_KEY base64 32-byte key for encrypted server secrets PUBLIC_ORIGIN comma-separated public origin(s) the CSRF check trusts; auto-detected from RENDER_EXTERNAL_URL / RAILWAY_PUBLIC_DOMAIN on those platforms TRUSTED_PROXY_CIDRS optional; trusts proxy socket peers for forwarded client-IP attribution only (audit logs, rate-limit keys) — NOT used for CSRF +NPM_REGISTRY_URL optional; registry for the Dependencies panel, the dependency resolver and runtime installs (default https://registry.npmjs.org); set it for a private registry or corporate mirror ``` Generate `INSTATIC_SECRET_KEY` with `bun run scripts/generate-secret-key.ts` before adding Anthropic, OpenAI, or OpenRouter credentials or enabling TOTP MFA in production. Without it, the admin can load but saving reversible secrets fails because there is no stable encryption key. diff --git a/docs/deployment/docker-image.md b/docs/deployment/docker-image.md index 3a92efc50..313b43378 100644 --- a/docs/deployment/docker-image.md +++ b/docs/deployment/docker-image.md @@ -144,6 +144,7 @@ Render auto-injects `RENDER_EXTERNAL_URL`, which Instatic uses as the CSRF publi | `INSTATIC_SECRET_KEY` | Yes for reversible server secrets | Output of `bun run scripts/generate-secret-key.ts` | | `PUBLIC_ORIGIN` | Behind managed HTTPS proxies | Comma-separated public origins for the CSRF check, e.g. `https://www.example.com`. Auto-detected from `RENDER_EXTERNAL_URL` / `RAILWAY_PUBLIC_DOMAIN` on those platforms | | `TRUSTED_PROXY_CIDRS` | Optional | Comma-separated trusted proxy CIDRs for client-IP attribution only (audit logs, rate-limit keys) — **not** used for CSRF. Trust only your real proxy CIDRs; never `0.0.0.0/0` for a public service | +| `NPM_REGISTRY_URL` | Optional | Registry used by the Dependencies panel, the dependency resolver and runtime `bun install`; defaults to `https://registry.npmjs.org`. Set for a private registry or mirror; downloads/advisories are skipped for non-public registries | Managed platforms usually inject `PORT`. Do not hard-code a different listen port unless the platform asks for a fixed target port. diff --git a/docs/e2e/README.md b/docs/e2e/README.md index 21c512ca0..b79ee01dd 100644 --- a/docs/e2e/README.md +++ b/docs/e2e/README.md @@ -225,7 +225,7 @@ durable assertion brittle: normalization, module dependency/importmap filtering, site runtime build, dependency resolver/cache, package importmap/server, malformed runtime-cache paths, and runtime asset publish injection are covered by focused Bun tests in - `src/__tests__/panels/depsSectionRuntime.test.tsx`, + `src/__tests__/panels/dependenciesPanel.test.tsx`, `src/__tests__/editor-hooks/useAutoResolveDependencies.test.tsx`, `src/__tests__/persistence/cmsRuntimeClient.test.ts`, `src/__tests__/server/cmsRuntimeHandlers.test.ts`, diff --git a/docs/e2e/feature-matrix.md b/docs/e2e/feature-matrix.md index 2f195d1ae..f8ee16b8c 100644 --- a/docs/e2e/feature-matrix.md +++ b/docs/e2e/feature-matrix.md @@ -172,12 +172,12 @@ SITE-019 note: `visual-builder.e2e.ts` saves a styled Container subtree as a lay | ID | Priority | Auto | Area | User Goal | Setup | Path | Expected Outcome | Watch For | |---|---:|:---:|---|---|---|---|---|---| | SITE-013 | P1 | partial | Code Editor | Author TypeScript site scripts with immediate type feedback | Site editor open | Code panel → New script → Code editor | `.ts`/`.tsx` scripts get DOM-aware completions, hover signatures, strict semantic diagnostics in a bounded collapsible Problems list, relative-file types, autosave, canvas execution, and publish compilation | worker startup, stale diagnostics, package types, completion keyboard reachability, classic-script confusion | -| SITE-014 | P1 | partial | Dependencies | Declare runtime packages for site scripts and plugin modules | Site script or module with package import | Dependencies panel and runtime resolve endpoint | Missing imports are visible, safe dependencies resolve into a lock/importmap, and cached package files serve under `/_instatic/runtime/cache` | unsafe package names, stale lock/importmap, install failures, traversal-shaped cache paths | +| SITE-014 | P1 | partial | Dependencies | Browse the npm registry and declare runtime packages for site scripts and plugin modules | Site script or module with package import | Dependencies panel (search, package page, install/remove), registry proxy, runtime resolve endpoint | Registry search and package pages load through the server proxy, missing imports are visible, safe dependencies resolve into a lock/importmap, and cached package files serve under `/_instatic/runtime/cache` | unsafe package names, registry outages surfacing as 502/504, stale lock/importmap, install failures, traversal-shaped cache paths | | SITE-016 | P1 | ✅ | Preview/Live | Compare the current draft with the live public route | Page has a published version and a later saved draft | Publish actions → Preview page; toolbar → Open live page | Preview iframe shows the current draft while the live route opens the last published output without admin chrome | draft/public leakage, stale live path, popup target, mobile overlay reachability | SITE-013 note: focused Bun coverage verifies the worker protocol/client, strict DOM-aware TypeScript diagnostics, DOM completion and hover results, relative cross-file typing, bare-package handoff to runtime analysis, `.tsx` path creation, CodeMirror compiler-diagnostic merging, lazy compiler isolation, and worker bundle budget. The 2026-08-11 agent-browser run covers live type-error rendering, `window` completion UI, hover information, autosave/reload, publish, and anonymous runtime execution; package declaration acquisition remains future work. -SITE-014 note: focused Bun coverage spans the dependency panel, auto-resolve hook, client envelope validation, runtime handler normalization, module dependency/importmap filtering, script import analysis, runtime config, site runtime build, dependency resolver/cache, package importmap/server, and runtime asset publish injection. `tests/e2e/runtime-dependencies.e2e.ts` covers browser authoring of a site script import, Dependencies-panel missing package Add, live `canvas-confetti` registry/cache resolution, save/publish, public importmap emission, browser loading of the emitted `/_instatic/runtime/cache/...` package URL, and a 390px mobile path that authors a missing import, opens Dependencies, verifies no horizontal overflow, and confirms the Add action is reachable. Live registry/install failure UX permutations remain operator-run. +SITE-014 note: focused Bun coverage spans the dependency panel, auto-resolve hook, client envelope validation, runtime handler normalization, module dependency/importmap filtering, script import analysis, runtime config, site runtime build, dependency resolver/cache, package importmap/server, and runtime asset publish injection. `tests/e2e/runtime-dependencies.e2e.ts` covers browser authoring of a site script import, Dependencies-panel missing package Add, live `canvas-confetti` registry/cache resolution, save/publish, public importmap emission, browser loading of the emitted `/_instatic/runtime/cache/...` package URL, a 390px mobile path that authors a missing import, opens Dependencies, verifies no horizontal overflow, and confirms the Add action is reachable, and a registry-browsing path that searches `pad-left`, opens its package page through the proxy, installs from the sticky install bar, waits for the lock, and removes it again (no dialog: nothing imports it and the confirm-before-delete preference is off by default). Registry proxy handler/client coverage (auth floor, query validation, scoped names, 502/504/404 mapping, TTL + single-flight cache) is Bun-level. Live registry/install failure UX permutations remain operator-run. SITE-016 note: `tests/e2e/preview-live.e2e.ts` creates a disposable page, publishes version A, saves draft version B without publishing, verifies the Preview page overlay iframe renders draft B, verifies the toolbar Open live page popup still serves published version A without editor chrome, and repeats preview opening at 390px to confirm the overlay remains reachable without document overflow. Issue #234 additionally gates Preview page through the server runtime-preview path so loop and media prefetch matches public rendering. Template-target and Content-entry live-path permutations remain lower-level or future browser coverage. diff --git a/docs/e2e/feature-validation.tsv b/docs/e2e/feature-validation.tsv index b575e1b88..c70662c9d 100644 --- a/docs/e2e/feature-validation.tsv +++ b/docs/e2e/feature-validation.tsv @@ -41,7 +41,7 @@ SITE-010 Responsive breakpoints and multi-frame canvas As a site editor, I want SITE-011 Class styles, selectors, and HTML attributes As a site editor, I want reusable classes, selectors, custom properties, and HTML attributes so published HTML/CSS stays clean and expressive. Properties/Selectors panels create/rename/delete classes/selectors, apply classIds to nodes, edit style declarations/pseudo/state selectors, and emit class CSS for canvas/publish. Duplicate class names; deleting used class; invalid CSS values; multi-selector edit; custom attr unsafe name/value. CSS values sanitized; selectors/classes stored in site document; HTML attribute helpers escape names/values and filter unsafe attrs. src/admin/pages/site/panels/PropertiesPanel; src/admin/pages/site/panels/SelectorsPanel; src/core/css-sanitize; src/core/publisher/cssCollector.ts Design tokens and CSS module rules apply only to admin CSS, not user CSS output. Happy: create class and apply style. Error: invalid CSS value rejected/ignored. Boundary: pseudo/state selector. Invalid: unsafe attr name. Permission: site.style.edit required. Performance: class suggestions fast. Mobile: panel controls fit. Class selector, ambient selector, pseudo-state selector, HTML attribute, breakpoint-context selector style, multi-selector bulk action, and custom property Playwright regressions passed 2026-06-22 for discovered SITE-011 subflows 0 None CAP-002 style persona could not edit content controls, enabled inline style editing, set Font size 22px, saved draft, and reload preserved the value. SITE-011 Playwright runs created a reusable class from the selected element ClassPicker, verified it in the Selectors panel, set Font size to 28px, saved/reloaded, and verified public published HTML kept the class and computed font size; created ambient selector p:not(.nope), verified it matched the selected Text node without assigning a class, set Font size to 30px, saved/reloaded, and verified the public paragraph had no class attr but kept computed font size; added safe data-track and unsafe onclick attributes, verified the validation error, saved/reloaded, and verified public output retained data-track while omitting onclick; created a:hover, set hover Font size to 31px, published, and verified the public link applied it only while hovered; created a class on a Button, set Desktop Font size to 20px, set Mobile Font size to 33px, verified desktop/mobile canvas frames independently, published, and verified public responsive computed CSS at default and 360px viewports; selected two reusable classes in the Selectors panel, bulk-applied them to a Button, duplicated and deleted the selected copies, saved/reloaded, published, and verified public HTML kept only the original applied classes; attempted invalid custom property name 123bad and saw the inline validation error, then added a valid --e2e-* property, saved/reloaded, published, and verified computed canvas/public custom-property CSS. Run logs: docs/e2e/runs/2026-06-22-site011-class-selector.md; docs/e2e/runs/2026-06-22-site011-ambient-selector.md; docs/e2e/runs/2026-06-22-site011-html-attributes.md; docs/e2e/runs/2026-06-22-site011-pseudo-selector.md; docs/e2e/runs/2026-06-22-site011-breakpoint-selector.md; docs/e2e/runs/2026-06-22-site011-bulk-selectors.md; docs/e2e/runs/2026-06-22-site011-custom-properties.md. Remaining: none identified for SITE-011. 2026-06-22 SITE-012 Framework colors, typography, spacing, and fonts As a site editor, I want reusable color tokens, font tokens, typography scales, spacing scales, and generated locked utility classes so the site design remains consistent across canvas and published pages. ColorsPanel writes framework color tokens and generated locked color classes; TypographyPanel and SpacingPanel write scale groups, manual/fluid settings, class-generator rows, and font tokens; buildFrameworkPlan emits one merged :root block plus generated utility classes; generated framework CSS is tree-shaken by default but can emit all utilities; font routes list bundled Google fonts, estimate/install Google selections, register custom media-backed fonts, and delete on-disk font families; font tokens emit CSS variables and update exact var(--font-*) references when renamed. Duplicate or colliding token slugs are deduped; generated class name collisions claim/remap user classes; orphan framework-prefixed classes are pruned; disabling utilities removes assigned classes; invalid color values are sanitized at CSS emission; rootFontSize cannot be below 1; unsafe font paths, unicode-range injection, wrong media MIME, empty family, empty variants/subsets, duplicate font token variables, and deleting a referenced font family are blocked or filtered. Framework and font settings are TypeBox-derived schemas; color slugs and font token variables normalize before storage; framework CSS values are sanitized before emission; font file paths must be /uploads/* or media-backed https; custom font variants parse before registration; all font endpoints require site.style.edit; admin mutation CSRF origin gate applies. src/admin/pages/site/panels/ColorsPanel; src/admin/pages/site/panels/TypographyPanel; src/admin/pages/site/panels/SpacingPanel; src/admin/pages/site/panels/FrameworkScalePanel; src/admin/pages/site/store/slices/site/framework; src/admin/pages/site/store/slices/site/fontActions.ts; src/core/framework; src/core/framework-schema; src/core/fonts; src/core/publisher/frameworkCss.ts; server/handlers/cms/fonts.ts Google font install tests should mock fetch or use bundled directory data to avoid external network dependence; manual browser coverage still needs a real narrow viewport pass through the panels. Happy: create color/font/typography/spacing tokens and emit classes/CSS. Error: Google install failure, missing custom media, duplicate font token variable. Boundary: min/max generated steps, manual scale mode, rootFontSize minimum, tree-shaken versus full generated CSS. Invalid: CSS value injection, unsafe font URL/path, unicode-range injection, non-font media MIME, empty family/variants/subsets. Permission: font endpoints require site.style.edit and forged origins are rejected. Performance: buildFrameworkPlan matches separate generators with one traversal. Mobile: panels render and scroll in manual exploratory pass. Focused automated audit passed 2026-06-23; manual exploratory Google-font network install and narrow-viewport panel scroll remain pending 0 None Implementation and test mapping refreshed in docs/e2e/runs/2026-06-23-site012-framework-fonts.md. 2026-06-23 SITE-013 Code editor for site files As a site editor, I want to create, preview, configure, edit, save, and publish site files so custom CSS, JavaScript, images, and binary assets can participate in the authored site without leaving the visual editor. CodeEditorPanel opens the active site file, lazy-loads CodeMirror for text/script/style/node-prop buffers, renders image previews for image files, renders a non-image binary placeholder for other binary assets, and exposes ScriptSettingsPane or StyleSettingsPane for runtime files. Site Explorer creates stylesheet files under src/styles/.css and script files under src/scripts/.ts, sets the new file active, and file mutations persist through site.files. Script settings control enabled state, module/classic format, canvas execution, placement, timing, priority, import diagnostics, and page/template scope. Stylesheet settings control enabled state, priority, and page/template scope. Deleting a runtime file clears its runtime config and active editor selection. Publisher emits enabled scoped user stylesheets into page-specific userStyles bundles and enabled scripts into published runtime assets. Empty text files remain editable and saveable; image files preview by URL while other binary files show a placeholder; invalid or unsafe paths are rejected; duplicate normalized paths are rejected; deleting an active file closes the editor; deleting scripts/styles removes runtime config; disabled or out-of-scope styles/scripts do not publish for the page; script import diagnostics do not run for classic scripts; CodeMirror content syncs through a short debounce before save. SiteFileSchema and SiteFileType validate persisted file shape; filesSlice normalizes paths, rejects traversal/empty/colliding paths, and routes content/blob updates by file kind; SiteRuntime schemas normalize script/style defaults and scope targeting; collectRuntimeScripts and collectAppliedStyles filter by enabled state, page/template scope, priority, and path order; collectUserStylesheetCss comment-wraps source paths and feeds the userStyles CSS bundle; public CSS links are emitted only for non-empty bundles. src/admin/pages/site/code-editor/CodeEditorPanel.tsx; src/admin/pages/site/code-editor/CodeMirrorEditor.tsx; src/admin/pages/site/code-editor/ScriptSettingsPane.tsx; src/admin/pages/site/code-editor/StyleSettingsPane.tsx; src/admin/pages/site/code-editor/AssetScopeControl.tsx; src/admin/pages/site/store/slices/filesSlice.ts; src/admin/pages/site/store/slices/sitePanelSlice.ts; src/core/files/schemas.ts; src/core/site-runtime/runtimeConfig.ts; src/core/site-runtime/schemas.ts; src/core/publisher/userStylesheets.ts; server/publish/siteCssBundle.ts CodeMirror is intentionally lazy-loaded; browser specs wait for the CodeMirror content element before typing; direct script authoring plus dependency resolution is tracked in SITE-014, while SITE-013 owns the generic file editor and stylesheet publish path; local E2E uses disposable SQLite data and can publish public pages without external services. Happy: create stylesheet from Site Explorer, type CSS in CodeMirror, save draft, publish, verify public page loads one /_instatic/css/userStyles-* link and applies the CSS. Error: invalid/duplicate/unsafe paths reject in files data-layer tests; resolve/import errors remain surfaced by the script settings/dependencies flows. Boundary: empty files, image preview, non-image binary placeholder, disabled runtime config, style/script priority ordering, page/template scope, and delete cleanup. Invalid: path traversal and unsafe normalized paths are rejected. Permission/security: runtime endpoints and site save/publish remain capability/step-up gated by surrounding flows; published output omits admin chrome and serves immutable hashed CSS. Performance: CodeMirror loads lazily and userStyles hashing changes only when relevant stylesheet content/config changes. Mobile/responsive: dependency/code-authoring mobile reachability is covered in SITE-014; broader standalone code-editor mobile exploration remains a residual manual check. SITE-013 automated audit passed 2026-06-23 with direct stylesheet browser coverage; no open defects; broader standalone mobile exploratory remains pending 0 None Added `tests/e2e/site-files.e2e.ts` for the missing direct stylesheet journey. Existing focused coverage maps script settings, style settings, CodeMirror changes, CodeMirror theme, file data-layer validation, runtime config normalization/scope/order, editor-store runtime cleanup, server userStyles bundle generation, publisher runtime assets, and agent code-asset tools. Verification this slice: TSV integrity guard passed; focused SITE-013 unit/integration `bun test` passed 101/101; focused `bun run test:e2e -- --project=e2e tests/e2e/site-files.e2e.ts -g "SITE-013"` passed 2/2 including setup; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5697/5697. Run log: docs/e2e/runs/2026-06-23-site013-code-editor-stylesheet.md. 2026-06-23 -SITE-014 Site dependencies and runtime package resolution As a site editor, I want to declare dependencies so plugin modules and site code can use runtime packages. Dependencies panel edits package metadata; POST /runtime/dependencies/resolve installs/resolves package import map; published pages serve cached runtime packages under /_instatic/runtime/cache. Invalid package.json; network/install failure; stale hash; package not found; runtime path 404 under namespace. Resolve body accepts unknown packageJson and normalizes to safe runtime dependencies only; devDependencies are not resolved into runtime importmaps; client validates dependencyLock/packageImportmap envelopes; runtime package paths require a 24-hex hash and reject traversal. src/admin/pages/site/panels/DependenciesPanel; src/admin/pages/site/hooks/useAutoResolveDependencies.ts; src/core/persistence/cmsRuntime.ts; server/handlers/cms/runtime.ts; server/publish/runtime/dependencyResolver.ts; server/publish/runtime/dependencyCache.ts; server/publish/runtime/packageImportmap.ts; server/publish/runtime/packageServer.ts Package installs may need network; deterministic tests use mocked registry/install/cache roots and UI fetch stubs. Happy: dependency panel detects imports, adds missing dependency, resolves lock/importmap, preview/build consumes runtime deps, package server serves cached package assets, public importmap points at hashed cache URLs, browser can load the emitted package asset, and mobile code authoring exposes the missing-dependency Add action without horizontal overflow. Error: resolve API failure surfaces in store; network/install timeout/cap/partial cache handled; missing package asset 404. Boundary: no dependencies, stale lock, importmap missing, concurrent resolves, cache sentinel, relative RUNTIME_CACHE_DIR. Invalid: unsafe package names, malformed lock envelope, bad runtime hash, traversal path. Permission: runtime.dependencies/site.read caps. Performance: install cache reuse and concurrent install dedupe. Mobile: Code Editor authoring and dependency panel controls stay reachable at 390px. Passed 2026-06-23: deterministic SITE-014 coverage (75 tests), cache layout regression (8 tests), Site Explorer layering invariant (33 tests), focused browser E2E (3 tests including setup), full bun test (5695 pass), lint, and build. 0 None Browser E2E now covers authoring a script import, Dependencies-panel missing package Add, live canvas-confetti registry/cache resolution, save/publish, public marker output, importmap emission, browser loading of /_instatic/runtime/cache package URLs, and a 390px mobile path for missing left-pad import analysis, dependency panel containment, and Add reachability. DEF-20260623-SITE014-01 closed: Code Editor floated above docked sidebars and intercepted Dependencies Add; fixed by raising site sidebars above floating editor panels and updating the layering invariant. DEF-20260623-SITE014-02 closed: relative RUNTIME_CACHE_DIR leaked relative paths to esbuild nodePaths and blocked publish with Could not resolve canvas-confetti despite an installed cache; fixed by absolute-normalizing cacheRootDir. DEF-20260623-SITE014-03 closed: mobile Code Editor authoring was blocked by fixed desktop panel sizing, a horizontal settings rail over the editor, and sidebar interception; fixed with responsive Code Editor viewport clamps, stacked settings panes on narrow screens, and a mobile overlay layer for the active Code Editor. Remaining: live registry/install failure UX permutations. 2026-06-23 +SITE-014 Site dependencies, registry browsing, and runtime package resolution As a site editor, I want to browse the npm registry and declare dependencies so plugin modules and site code can use runtime packages. Dependencies panel searches the registry through GET /registry/search, shows package pages (README, versions, dependencies, OSV advisories, ESM preflight) from GET /registry/packages/:name*, installs into package metadata, and removes behind the editor's confirm-delete dialog (always shown; the usage scan only changes its wording); POST /runtime/dependencies/resolve installs/resolves the package import map; published pages serve cached runtime packages under /_instatic/runtime/cache. Invalid package.json; registry outage or timeout (502/504); unknown package (404); network/install failure; stale hash; runtime path 404 under namespace. Registry routes sit on site.read and validate names with isSafePackageName plus TypeBox query bounds; the registry host comes from NPM_REGISTRY_URL only; install/remove/runtime-issue actions require runtime.dependencies and stay disabled with a tooltip otherwise; resolve body accepts unknown packageJson and normalizes to safe runtime dependencies only; devDependencies are not resolved into runtime importmaps; client validates every proxied envelope against @core/registry schemas; runtime package paths require a 24-hex hash and reject traversal. src/admin/pages/site/panels/DependenciesPanel; src/admin/pages/site/hooks/useAutoResolveDependencies.ts; src/core/registry; src/core/persistence/cmsRegistry.ts; src/core/persistence/cmsRuntime.ts; server/handlers/cms/registry.ts; server/registry; server/handlers/cms/runtime.ts; server/publish/runtime/dependencyResolver.ts; server/publish/runtime/dependencyCache.ts; server/publish/runtime/packageImportmap.ts; server/publish/runtime/packageServer.ts Package installs may need network; deterministic tests use mocked registry/install/cache roots and UI fetch stubs. Happy: registry search lists packages, a package page shows README/versions/deps/advisories and installs the picked version, dependency panel detects imports, adds missing dependency, resolves lock/importmap, preview/build consumes runtime deps, package server serves cached package assets, public importmap points at hashed cache URLs, browser can load the emitted package asset, and mobile code authoring exposes the missing-dependency Add action without horizontal overflow. Error: resolve API failure surfaces in store; network/install timeout/cap/partial cache handled; missing package asset 404. Boundary: no dependencies, stale lock, importmap missing, concurrent resolves, cache sentinel, relative RUNTIME_CACHE_DIR. Invalid: unsafe package names, malformed lock envelope, bad runtime hash, traversal path. Permission: browsing on site.read; install/remove/Add/Move disabled without runtime.dependencies; proxy routes reject unsafe names (400), unknown packages (404), upstream failures (502/504). Performance: install cache reuse and concurrent install dedupe. Mobile: Code Editor authoring and dependency panel controls stay reachable at 390px. Passed 2026-09-04: registry proxy + panel rebuild, focused browser E2E (3 SITE-014 tests, 8 including fixtures), full bun test, lint, and build. 0 None Browser E2E covers authoring a script import, Dependencies-panel missing package Add, live canvas-confetti registry/cache resolution, save/publish, public marker output, importmap emission, browser loading of /_instatic/runtime/cache package URLs, a 390px mobile path for missing left-pad import analysis, dependency panel containment and Add reachability, and a registry-browsing path that searches pad-left, opens its package page through the proxy, installs from the sticky install bar, waits for the lock, and removes it again. DEF-20260623-SITE014-01 closed: Code Editor floated above docked sidebars and intercepted Dependencies Add; fixed by raising site sidebars above floating editor panels and updating the layering invariant. DEF-20260623-SITE014-02 closed: relative RUNTIME_CACHE_DIR leaked relative paths to esbuild nodePaths and blocked publish with Could not resolve canvas-confetti despite an installed cache; fixed by absolute-normalizing cacheRootDir. DEF-20260623-SITE014-03 closed: mobile Code Editor authoring was blocked by fixed desktop panel sizing, a horizontal settings rail over the editor, and sidebar interception; fixed with responsive Code Editor viewport clamps, stacked settings panes on narrow screens, and a mobile overlay layer for the active Code Editor. Remaining: live registry/install failure UX permutations. 2026-09-04 SITE-015 Site editor media explorer and picker As a site editor, I want to browse, upload, reuse, inspect, edit, and apply media from inside the site editor so image, video, SVG, and background/media controls can use CMS assets without leaving the authoring workflow. MediaLibraryControl renders library and URL modes for image/video props, lazy-loads MediaPickerModal on Browse, filters by media kind, updates the prop with the picked asset publicPath, supports clearing, and opens MediaViewerWindow for the selected CMS asset. MediaExplorerPanel is a docked left-rail panel that lists CMS assets grouped as Images, Videos, and Other, supports search and list/grid view persistence, uploads assets through CMS media APIs, opens the shared viewer, exposes context menu actions for Copy URL, Rename, Delete, and conditionally Use in selected image/video when the selected canvas node matches the asset kind. Published pages render selected local media through /uploads URLs. Empty library shows bucket empty states; image/video/other assets are bucketed by MIME type; selected image/video actions only appear for matching module and asset kind; unsupported uploads return an alert through the media upload queue; deleted or missing selected paths fall back to saved-path labels; URL mode accepts local /uploads paths plus http/https URLs and rejects invalid image/video URLs; SVG uploads are sanitized before serving; viewer edits/removal update local asset state; upload queue and media viewer can be closed without leaving the editor. CMS media responses are validated by @core/persistence/cmsMedia; uploads are server magic-byte/type/size checked and routed through the media presentation pipeline; MediaLibraryControl validates URL mode before calling onChange; MediaExplorerPanel applies assets only to selected base.image src or base.video videoUrl props; media routes require the relevant media capabilities; publisher escapes/render-validates media URLs and visitor pages must not include admin chrome. src/admin/pages/site/panels/MediaExplorerPanel/MediaExplorerPanel.tsx; src/admin/pages/site/panels/MediaExplorerPanel/mediaExplorerUtils.ts; src/admin/pages/site/property-controls/MediaLibraryControl.tsx; src/admin/pages/site/property-controls/ImageControl.tsx; src/admin/pages/site/property-controls/BackgroundImageControl.tsx; src/admin/pages/media/components/MediaPickerModal/MediaPickerModal.tsx; src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx; src/admin/pages/media/hooks/useStandaloneMediaEditor.ts; src/core/persistence/cmsMedia.ts; server/handlers/cms/media.ts; server/handlers/cms/mediaUpload.ts; src/modules/base/image; src/modules/base/video The docked Media Explorer and the property-control picker intentionally share CMS media and viewer primitives with the Media workspace; direct background-image picker publishing remains covered by lower-level style/publisher tests rather than a dedicated browser journey; the new SITE-015 browser regression uses disposable SQLite/uploads and verifies public media output as a visitor. Happy: upload an image through the property picker, select it for an image module, save, publish, and verify public /uploads image decoding; reuse the same library asset on a second image without re-upload; upload an image through the docked Media Explorer, use its context menu to apply it to the selected image module, save, publish, and verify public /uploads image decoding. Error: unsupported upload shows specific rejection feedback; media API errors surface inline or restore optimistic state. Boundary: empty library, search filters, list/grid view, image/video/other grouping, selected image/video context action gating, metadata rename/edit/reload persistence, replace/delete/restore/purge lifecycle, and sanitized SVG serving. Invalid: bad URL-mode values are rejected before prop update; unsafe SVG script/event/style content is stripped. Permission/security: media APIs require media capabilities and public visitor pages show only uploaded media, not admin chrome. Performance: MediaPickerModal lazy-loads only after Browse; media panel fetches assets when opened. Mobile/responsive: media viewer, replace dialog, trash restore, and storage panel have mobile containment coverage; docked editor Media Explorer mobile remains a residual exploratory check. SITE-015 browser regression passed 2026-06-23 with direct docked Media Explorer apply-to-selected-image coverage; no open defects; docked Media Explorer mobile exploratory remains pending 0 None Added SITE-015 coverage to `tests/e2e/media.e2e.ts`: create a page, insert/select an image module, open the Media panel, upload an image through the docked panel, use the asset context menu `Use in selected image`, save, publish, and verify the public page serves/decodes the uploaded image. Existing coverage in `media.e2e.ts` covers picker upload/select/publish, asset reuse, unsupported upload rejection, metadata persistence, mobile metadata viewer containment, replace/delete/restore/purge lifecycle, mobile lifecycle containment, storage panel state/mobile containment, and SVG sanitization. Focused component coverage in `siteExplorerPanel.test.tsx` covers Media Explorer grouping, search/list/grid, copy URL, selected image/video apply actions, viewer opening, rename, and delete. Verification this slice: TSV integrity guard passed; focused SITE-015 unit/component/API `bun test` passed 102/102; focused `bun run test:e2e -- --project=e2e tests/e2e/media.e2e.ts -g "SITE-015"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/media.e2e.ts` passed 12/12 including setup; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5697/5697. Run log: docs/e2e/runs/2026-06-23-site015-media-explorer.md. 2026-06-23 SITE-016 Preview overlay and live-page opening As a site editor, I want to preview the current draft and open the live route so I can compare draft and published output. Preview page is exposed from the publish-actions menu; PreviewOverlay mounts only when previewOpen, requires an active site and active page, posts the current in-memory site and active page to the CMS runtime-preview endpoint and renders the server-built document in a sandboxed iframe srcDoc, shows the active page title, closes by Close button/Escape/backdrop, and restores focus. OpenLivePageButton is globally mounted in the toolbar, reads adminUi.activeLivePath, and opens that path in a new noopener/noreferrer tab or falls back to the site root. useActiveLivePath publishes regular page paths, template preview targets, post-type preview permalinks, or /404 for not-found templates. No active site/page renders no overlay; unpublished saved drafts can preview without changing public output; live route shows last published artefact until publish; activeLivePath null opens root; template pages are not directly routable and resolve to their preview target; popup uses the current dev/admin origin but Vite proxies public routes; narrow viewport must keep preview reachable and document width contained. Preview state is owned by uiSlice openPreview/closePreview; PreviewOverlay sends the validated in-memory draft to the site.read-gated runtime-preview endpoint, which prefetches loop and media data before the publisher boundary sanitizes emitted HTML; iframe uses sandbox="" with no allow flags; OpenLivePageButton receives the already-resolved public path from adminUi; save/publish remain capability and step-up gated by surrounding toolbar flows. src/admin/pages/site/toolbar/PublishButton.tsx; src/admin/pages/site/toolbar/PublishActionGroup.tsx; src/admin/pages/site/preview/PreviewOverlay.tsx; src/admin/pages/site/store/slices/uiSlice.ts; src/admin/pages/site/hooks/useActiveLivePath.ts; src/admin/shared/OpenLivePageButton/OpenLivePageButton.tsx; src/core/persistence/cmsRuntime.ts; server/handlers/cms/runtime.ts; server/publish/runtime/previewRuntime.ts; src/core/publisher; src/core/page-tree/page.ts Public live routes intentionally show the last published version, not the saved draft; this slice covers regular pages, while template/content-entry live-path permutations remain covered lower-level or future browser coverage. Happy: create page, publish version A, save draft version B, open Preview page and verify iframe shows B, open live page and verify popup route shows A without admin chrome. Error: no-site/no-active-page overlay and close behaviours covered by component tests. Boundary: activeLivePath root fallback, home path, content entry path, template target resolver, and mobile 390px preview reachability. Invalid: missing runtime preview body remains covered by server runtime tests, not this client overlay. Permission/security: publish/save capability and step-up gates surround the flow; public popup has no admin chrome. Performance: preview lazy-loads the overlay and starts one abortable runtime-preview request only when opened. Mobile: overlay opens at 390px without document overflow. SITE-016 browser verification passed 2026-07-22 after issue #234 restored loop parity in Preview page; draft-vs-live comparison and mobile preview reachability remain covered; template/content live-path browser permutations remain residual risk 0 None Added `tests/e2e/preview-live.e2e.ts`: publish a disposable page with text A, save draft text B without publishing, verify Preview page iframe shows B and not A, verify toolbar Open live page popup shows A and not B without editor chrome, then reopen Preview page at 390x844 and verify no document overflow. Initial focused run failed because the new spec selected a layer while the Site Explorer was open; root cause was a spec precondition, fixed by opening the Layers panel before selecting the Text node. Verification this slice: TSV integrity guard passed; focused preview/live unit suite passed 52/52; focused `bun run test:e2e -- --project=e2e tests/e2e/preview-live.e2e.ts -g "SITE-016"` passed 2/2 including setup; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5697/5697. Run logs: docs/e2e/runs/2026-06-23-site016-preview-live.md and docs/e2e/runs/2026-07-22-issue-234-preview-loop-retest.md. Issue #234 was reproduced with a `site.pages` loop visible in the canvas and public route but missing from Preview page. PreviewOverlay now delegates current-draft rendering to the server runtime-preview boundary, which prefetches loop and media data before publishing. Verification passed: same-flow Chromium retest plus live-route check, focused runtime/preview tests 44/44, full `bun test` 6237/6237, `bun run lint`, and `bun run build`. 2026-07-22 SITE-017 Visual components and slots As a site editor, I want to componentize authored page content, add reusable slots, fill those slots on a page, and publish the resulting page as clean visitor HTML. Componentize converts the selected page node into a Visual Component row with a base.body definition root, replaces the original page node with a base.visual-component-ref, and switches the editor into VC mode. Adding a base.slot-outlet to the VC definition creates a locked base.slot-instance child on the page ref when returning to the page. The locked slot row hides destructive structural actions but accepts inserted child content. Save writes component rows before page rows so newly-created component refs validate; publish inlines the component definition and slot fill into the public artefact without editor slot labels or component names. Duplicate or blank component names; conversion attempted from VC mode, body/root, or an existing component ref; recursive refs; unknown or missing componentId; slot outlet rename/reorder/delete; empty or default slots; nested refs; page save racing a new component save; save/publish/reload after slot fill insertion. Component names and recursion use typed VisualComponent errors; VC/page shapes are TypeBox-validated through component/page adapters; syncSlotInstances materializes locked slot instances from slot outlets; dirty tracking marks both edited page and created component; CmsAdapter writes components before pages; publisher renderVisualComponentRef resolves refs, expands slot-instance children at matching outlets, and sanitizes emitted HTML. src/admin/pages/site/panels/PropertiesPanel/ConvertToComponentButton.tsx; src/admin/pages/site/store/slices/visualComponentsSlice.ts; src/admin/pages/site/store/slices/site/collabBinding.ts; src/core/collab/applyPatches.ts; server/handlers/cms/pages.ts; server/handlers/cms/components.ts; src/core/visualComponents; src/modules/base/visualComponentRef; src/modules/base/slotOutlet; src/modules/base/slotInstance; src/core/publisher/renderVisualComponentRef.ts Visual Components persist as rows in the components system table; page rows can reference a component only after that component row is stored; E2E uses disposable local SQLite/uploads and fresh owner login because publish rotates the session. Happy: componentize Text, create a slot outlet, return to page, insert Text into the locked slot, save, publish, and verify anonymous public page contains component body plus slot fill. Error: adapter ordering regression prevented new component refs from validating during page save. Boundary: locked slot row remains operable while hiding Rename/Duplicate/Cut/Delete; empty/default/nested/unknown component behavior covered by lower-level suites. Invalid: blank/duplicate names and recursive refs rejected by focused tests. Permission: publish step-up exercised; fine-grained capability variants remain lower-level/future browser coverage. Performance: save ordering is sequential only where required, layouts remain independent. Mobile: VC publish journey desktop-covered; mobile VC editing remains residual. SITE-017 browser regression passed 2026-06-23 after DEF-20260623-SITE017-001 fix; no open high/critical defects for this feature slice; mobile and permission permutations remain residual 0 None DEF-20260623-SITE017-001 fixed: publishing a freshly componentized page could emit an empty body because CmsAdapter saved pages and components in parallel, and the pages endpoint stripped the new base.visual-component-ref as dangling when it validated before the component row committed. Fix: write /admin/api/cms/components before starting /admin/api/cms/pages. Added `tests/e2e/visual-builder.e2e.ts` SITE-017 public publish journey, `src/__tests__/persistence/cmsAdapter.test.ts` ordering regression, and `src/__tests__/editor-store/dirtyTracking.test.ts` page+component dirty-mark guard. Verification passed: TSV integrity guard, focused VC suite 243/243, cmsAdapter 11/11, dirtyTracking 29/29, Playwright SITE-017 2/2 including setup, `bun run lint`, `bun run build`, and full `bun test` 5699/5699. Run log: docs/e2e/runs/2026-06-23-site017-visual-components.md. 2026-06-23 diff --git a/docs/editor.md b/docs/editor.md index 2de45ee41..f93b5a9e6 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -528,7 +528,7 @@ Opens the rail-selected panel: - Active tab is held in `explorerPanelTab` (`uiSlice`, `'layers' | 'site' | 'code' | 'media'`) and persisted per-workspace via `siteEditorLayoutPersistence` (stored field `explorerPanelTab`). - `FrameworkPanel` — site-level design tokens (the Core Framework) in one panel with **Overview / Colors / Type / Space** tabs. Its "Manage framework" button opens `FrameworkManagerDialog`, a declarative state picker (Full framework / Variables only / None) that reconciles the framework to the chosen target. Sits **above** Selectors in the rail. - `SelectorsPanel` — CSS class library -- `DependenciesPanel` — site package.json / `bun install` +- `DependenciesPanel` — npm registry browser + the site's `package.json`: search, package pages (README, versions, deps, advisories), install / remove, runtime-import issues. See [`features/dependencies.md`](features/dependencies.md). - `PluginEditorPanel` — plugin-provided editor panels - `AgentPanel` — AI assistant diff --git a/docs/features/dependencies.md b/docs/features/dependencies.md new file mode 100644 index 000000000..8096c8ba1 --- /dev/null +++ b/docs/features/dependencies.md @@ -0,0 +1,108 @@ +# Dependencies + +The Site → Dependencies panel is where a site's npm packages are found, inspected, installed and removed. + +It browses the whole registry through a server-side proxy, and every install lands in the site's `package.json` (`SitePackageJson`, see [`site-shell.md`](site-shell.md)) where the runtime resolver turns it into a locked, self-hosted package the published page can import. The source of truth for every proxied shape is `src/core/registry/schemas.ts`; for what a site declares it is `SitePackageJsonSchema` in `src/core/site-dependencies/manifest.ts`. + +--- + +## TL;DR + +- **Panel:** `src/admin/pages/site/panels/DependenciesPanel/`. `RegistryPanel` owns one search box; an empty query shows `HomeView` (installed packages, and on the public registry a curated "popular for sites" set plus category shortcuts), typing shows `ResultsView`, opening anything shows `PackageDetailView` with a sticky `InstallControl`. +- **Registry access is server-side only.** The browser never talks to npm. `server/handlers/cms/registry.ts` exposes six read-only routes on the `site.read` floor; `server/registry/client.ts` does the upstream reads with a bounded TTL cache. +- **Shared shapes:** `@core/registry` — TypeBox schemas for every proxied response, `pickEsmEntry` (the importmap builder's entry rules, reused as a pre-install compatibility badge), `cleanPackageDescription`. +- **Installing is a store mutation.** `setDependency(name, range, dev)` writes the manifest; `useAutoResolveDependencies` (mounted by the editor) posts to `/runtime/dependencies/resolve`, which resolves, `bun install`s and returns the lock + importmap. Removing is `removeDependency(name)` behind the editor's confirm-delete dialog (`useConfirmDelete`), always confirmed because a manifest change reaches past the editor, and it never needs the registry to answer. +- **Capabilities:** browsing needs `site.read`; install, remove, resolve and the runtime-issue actions need `runtime.dependencies`. Without it the controls stay visible but disabled, with the reason as their tooltip, rather than failing on click. +- **Config:** `NPM_REGISTRY_URL` (parsed once by `readServerConfig`, applied through `configureNpmRegistryUrl`) points search, packuments, the resolver and `bun install` at one registry (default `https://registry.npmjs.org`). `GET /admin/api/cms/registry` tells the panel the registry's host and whether it is the public one (`RegistryProfile`); only the public registry gets npm's search qualifiers, download statistics (`api.npmjs.org`), OSV advisories and npmjs.com links. A private registry gets plain search and packuments, its package names never leave the server, and the home view drops the npm-only sections. A profile that fails to load is not treated as private: the panel shows the error with a retry, because silently hiding half the panel would be indistinguishable from a mirror. + +--- + +## The panel + +```text +RegistryPanel search box · runtime-issue banner · view switch +├── HomeView Installed (+ resolve status) · Dev dependencies · [public npm] Popular for sites · Browse by category +├── ResultsView result tiles · sort (match / popular / updated) · show more +└── PackageDetailView hero · badges · keywords · stat tiles · Readme | Versions | Deps | Security · links + └── InstallControl version picker + Install (split: dev dependency) | installed pill + Remove (confirm dialog) +``` + +Data hooks live in `useRegistryData.ts` (the registry profile, a paged search accumulator whose page count lives under the query key, one `useAsyncResource` per other request; the proxy's `Cache-Control` lets the browser cache absorb repeats) and `useInstalledDependencies.ts` (store selectors, usage map, runtime-issue summary, lock sync, resolve status, capability gate). Manifest reads go through `readDeclaredDependency` in `src/core/site-dependencies/manifest.ts`, which uses `Object.hasOwn`: `isSafePackageName` accepts `constructor`, and a bare `in` or bracket read would report it installed with a `Function` for a version. The store's add/remove guards and the module engine read through the same helper. `curated.ts` holds the npm-only editorial content (popular packages with our own one-line blurbs, category shortcuts); reading those blurbs from the registry would cost eight parallel search calls on every panel open. `PackageTiles.tsx` holds the presentational pieces and the shared row classes; its `Tile` renders through the `Button` primitive (ghost, start-aligned) with the tile chrome layered on by class, the same way the module inserter's tile items do. Empty sections use the `EmptyState` primitive inside a static tile; hover hints on the package page use the `Tooltip` primitive, never `title=`. + +Visual rules follow [`../design.md`](../design.md) ("Cards are tiles, not boxes"): borderless `--bg-surface-2` tiles on the sidebar's `--bg-surface` with a 1px gap and `--card-radius`; each package gets an identity accent through `railAccent(name)` (`tint.ts`) for its monogram and sparkline; state colour is reserved for installed / update-available / security. + +### What the user sees per package + +| Signal | Source | +|---|---| +| Weekly downloads, sparkline | `GET …/packages/:name/downloads` (30 daily points, `weekly` = last 7; `null` when the registry has no stats) | +| Install version choices | `latest`, then the other dist-tags, then the newest non-deprecated releases (`packageVersions.ts`); the first entry is the one-click default, and the tags precede the capped release list so a `next` build stays reachable | +| Dependents, publisher | the search-index hit the user came from, or one exact-name lookup (`usePackageHit`) | +| TS / ESM / main only / no entry badges | `RegistryVersionInfo.hasTypes` + `esmEntry` from `pickEsmEntry` on the latest manifest | +| deprecated / insecure | packument `deprecated`, search-index `flags.insecure` | +| README | packument `readme` → `renderMarkdownToHtml` → `sanitizeRichtext(html, MARKDOWN_DOCUMENT_CONFIG)` (`readmeHtml.ts`), rendered once per package page | +| Versions (newest 100 of N), dist-tags, sizes | packument `versions` + `time`; `versionCount` carries the full total | +| License | the latest version's, or the packument-level one for old packages | +| Security | `POST api.osv.dev/v1/query` for the latest version (public npm only) | +| Update available (installed rows) | `GET …/packages/:name/latest` vs the locked version | +| in use | `analyzeRuntimeScriptImports` + `getSiteModuleDependencyUsage` (`runtimeIssues.ts`) | + +Runtime-script diagnostics (`runtime-dependency-missing`, `-dev-only`, `-node-builtin`, `-invalid-name`) render above the home and results views as the "Runtime dependency issues" group with **Add** / **Move** actions, exactly the surface `tests/e2e/runtime-dependencies.e2e.ts` drives. + +A package the search index does not list (just published, deprecated, or a private registry without `/-/v1/search`) is still reachable: a typed name that is a valid package name gets an "Open package" tile above the results, and Enter opens that name directly. The package page fetches the packument by name, so it works whenever the registry does. A search endpoint that answers 404 counts as an empty result, not an error. + +Installing writes the manifest; the resolve that follows runs in `useAutoResolveDependencies`, which the editor body mounts alongside the panel. A failed resolve shows its message and a **Retry resolve** button both on the package page's install bar and in the home view's Installed title. The install bar renders for an installed package even while its details are loading or failed, so a typo'd or vanished package can always be removed. + +--- + +## The proxy + +```text +GET /admin/api/cms/registry → RegistryProfile { host, publicNpm } +GET /admin/api/cms/registry/search?q=&sort=relevance|popularity|maintenance&from=&size=&deprecated=hide|show +GET /admin/api/cms/registry/packages/:name → RegistryPackageDetails +GET /admin/api/cms/registry/packages/:name/latest → { version | null } +GET /admin/api/cms/registry/packages/:name/downloads → { daily[], weekly | null } +GET /admin/api/cms/registry/packages/:name/advisories?version= → { advisories[] } +``` + +- Scoped names travel URL-encoded (`@scope%2Fname`); the route-table dispatcher decodes once and the handler validates with `isSafePackageName` before the name touches a URL. Query params are validated with TypeBox (`q` 1–200 chars, `size` ≤ 50, `from` ≤ `REGISTRY_MAX_SEARCH_FROM`); that bound and the page size are exported from `@core/registry`, so the panel's paging and the route's validation cannot drift. `deprecated=hide` appends npm's `not:deprecated` qualifier on the public registry only; a private registry would match it as literal text. +- The registry host is server config only (`npmRegistryUrl()` in `server/registry/config.ts`); nothing in a request can redirect a read. A malformed `NPM_REGISTRY_URL` logs a warning and falls back to the public registry. The profile route exposes the host, never the URL, which may carry credentials. +- Upstream failures map to `502` (`RegistryUpstreamError` status / network / shape / too-large), `504` (timeout) and `404` (unknown package); the panel shows them inline with a retry. Budgets cover the whole exchange, body included: 10 s for search and stats, 30 s for a full packument, 60 s for an install document (what `bun install` gets). Bodies are counted as they arrive and refused past 32 MB, so neither compression nor chunked encoding can hide a document's real size. A caller's `AbortSignal` is honoured only for uncached reads: a cached load is shared by every caller on that key, and letting the first one cancel it would fail the rest. +- Responses carry `Cache-Control: private, max-age=…` derived from the one `TTL` table in `client.ts` (search 5 min, details and latest 10 min, downloads 6 h, advisories 1 h) so the browser cache backs the server's `TtlCache` (`server/registry/cache.ts`: one TTL per cache, insertion order is expiry order, bounded, single-flight loads). Injecting `fetchImpl` (tests) bypasses the caches. +- Only projections are cached (`RegistryPackageDetails`, search pages, stats), never raw packuments: a popular packument is tens of megabytes of JSON. Package details project only the newest 100 versions plus every dist-tagged version (ordered by publish time, falling back to semver when the packument carries no `time`, so `latest` is always present), `versionCount` carries the real total, a dist-tag whose version is gone is dropped rather than offered as an install choice, and a README over 256 kB is truncated. Loading details also seeds the `latest` cache, so the home row and the package page agree without a second request; a cold `latest` read fetches the one-version manifest `//latest` instead of the packument. An injected `fetchImpl` neither reads nor writes any cache, so a test cannot leave data behind for another. +- Only the fields an install depends on are typed in `PackumentSchema`; decorative metadata (`description`, `readme`, `keywords`, `maintainers`) is `Unknown` and filtered, so an odd shape there can never make a package impossible to resolve. +- The dependency resolver (`server/publish/runtime/dependencyResolver.ts`) reads through the same client but always fresh (`getInstallPackument`, npm's abbreviated install document: dist-tags and tarballs without README or `time`), so it sees a version the moment it is published even while the browsing cache still shows the previous one. It resolves at most five packages at a time and reports every failure in one message, so a thirty-dependency manifest neither opens thirty simultaneous downloads nor needs one attempt per bad name. `dependencyCache.ts` exports `NPM_CONFIG_REGISTRY` to `bun install` when a non-default registry is configured. +- Upstream timeouts cover the body read too: a registry that sends headers and then stalls yields a 504 instead of pinning the single-flight key. + +--- + +## Forbidden patterns + +- **Talking to a registry from the browser.** The panel only calls `/admin/api/cms/registry/*`; the registry host, credentials and caching live on the server. A `fetch('https://registry.npmjs.org…')` in `src/admin` bypasses `NPM_REGISTRY_URL` and the boundary validation. +- **Reading a package for install from the browsing cache.** `getInstallPackument` is uncached on purpose; only `getPackageDetails` (a projection) is cached. Caching packuments reintroduces stale installs and multi-megabyte cache entries. +- **Building a registry URL from an unvalidated name.** Every handler and client entry runs `isSafePackageName` first; the route regex only captures the segment, it does not validate it. +- **Rendering README HTML without `MARKDOWN_DOCUMENT_CONFIG`.** The richtext config strips images and tables; the plain publisher output is unsanitised. `readmeHtml.ts` is the one place both steps meet. The profile carries `_externalImagesOnly`, which is what makes the sanitizer's attribute hook drop non-http image sources; the hook does nothing to images under any other profile. The profile also drops `id` and `class` from the richtext attribute set: a README is third-party markup rendered inside the admin, and an attacker-chosen `id` would collide with the app's own elements. +- **A second `useInstalledDependencies()` per view.** It walks every script and page; `RegistryPanel` calls it once and passes the result down. +- **Deciding "public npm" per response.** The panel reads it once from the profile route; `RegistryPackageDetails` carries no registry flag. + +## Tests + +- `src/core/registry/__tests__/esmEntry.test.ts` — entry rules, description cleaning. +- `server/registry/__tests__/client.test.ts`, `cache.test.ts` — URL building, public-only qualifiers, sort weights, mapping, loose metadata, abbreviated documents, dangling dist-tags, README truncation, caller-abort, cache bypass under an injected fetch, 404 / timeout / shape errors, TTL sweep + single-flight, profile. +- `server/handlers/cms/__tests__/registry.test.ts` — auth floor, profile, query validation, scoped names, upstream error mapping, 405. +- `src/__tests__/panels/dependenciesPanel.test.tsx` — installed rows, runtime issues, resolve status, search → detail → install, latest-first version default, paging reset, removal (confirm dialog, and without the registry), declaring a package the registry cannot describe, a failed registry profile, prototype-named packages, capability gating (install, remove and resolve). +- `tests/e2e/runtime-dependencies.e2e.ts` (SITE-014) — the live browser path, including browsing the registry and installing from a package page. +- `src/__tests__/core/markdownDocumentSanitize.test.ts` — what a README may and may not render, and that the image policy is profile-scoped. + +## Related + +- [`site-shell.md`](site-shell.md) — `SitePackageJson`, `SiteRuntimeConfig`, the dependency lock and the importmap the publisher emits. +- [`publisher.md`](publisher.md) — how locked packages are served from `/_instatic/runtime/cache//`. +- [`auth-and-access.md`](auth-and-access.md) — the `site.read` / `runtime.dependencies` capabilities. +- [`../server.md`](../server.md) — CMS route dispatch and the boot-time config that sets the registry URL. +- [`../design.md`](../design.md) — the tile-card pattern the panel is built on. + +Source-of-truth files: `src/core/registry/schemas.ts`, `src/core/registry/esmEntry.ts`, `server/registry/client.ts`, `server/registry/config.ts`, `server/handlers/cms/registry.ts`, `src/core/persistence/cmsRegistry.ts`, `src/admin/pages/site/panels/DependenciesPanel/RegistryPanel.tsx`, `src/admin/pages/site/panels/DependenciesPanel/useInstalledDependencies.ts`. + +Gate tests: `src/__tests__/architecture/no-core-barrel-deep-imports.test.ts` (`@core/registry` is a gated barrel), `src/__tests__/architecture/boundary-validation.test.ts`, `src/__tests__/architecture/no-native-title-tooltips.test.ts`, `src/__tests__/store/selectorStability.test.ts`. diff --git a/docs/features/media.md b/docs/features/media.md index ef878c294..97a132c45 100644 --- a/docs/features/media.md +++ b/docs/features/media.md @@ -49,7 +49,7 @@ src/admin/pages/media/ └── utils/ ├── filters.ts — type/date/folder filter predicates ├── folderTree.ts — folder utilities: tree build, descent check, child listing - ├── formatBytes.ts — binary-unit file-size formatter (B/KB/MB/GB) shared by canvas tiles, viewer, upload queue, replace dialog +│ (byte formatting lives in `src/admin/lib/formatBytes.ts`, shared with the Dependencies panel) ├── mediaDnd.ts — drop-legality rules: canMoveFolderTo, canAcceptDrop, commitDropPayload, MediaDndTarget ├── mediaDragDrop.ts — TypeBox-validated drag/drop payload helpers ├── smartFolders.ts — smart folder IDs, type guard, per-ID predicates diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 512b056ca..9d207d8a2 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -300,7 +300,7 @@ type SitePackageJson = { The CMS supports plugins that ship their own npm deps and runtime imports (e.g. `three`). When a site declares a dependency, `bun install` runs against a per-site workspace under `uploads/sites//runtime/`, producing a hashed cache directory the server serves at `/_instatic/runtime/cache//...`. The runtime cache layout is owned by `src/core/site-runtime/` and served by `server/publish/runtime/`. -The Site → Dependencies panel edits this `package.json`. Saving triggers a `bun install` and updates the runtime lock. +The Site → Dependencies panel edits this `package.json`: it browses the npm registry through the server proxy and installs into `dependencies` (or `devDependencies`) with a caret range. Every manifest change is picked up by `useAutoResolveDependencies`, which resolves the lock and runs `bun install`. Panel and proxy: [`dependencies.md`](dependencies.md). ### `SiteRuntimeConfig` @@ -695,7 +695,7 @@ createFile('src/styles/analytics.css', 'style', '/* ... */') ### Declare a site dependency -Site → Dependencies panel edits `packageJson.dependencies`: +Site → Dependencies panel (search the registry, open the package, Install) writes `packageJson.dependencies`; the same happens when a runtime-script diagnostic's **Add** action runs or a module declares a dependency: ```jsonc { @@ -703,7 +703,7 @@ Site → Dependencies panel edits `packageJson.dependencies`: } ``` -Save → server runs `bun install` in the per-site workspace → `runtime.dependencyLock` updates → the publisher emits a `
', + MARKDOWN_DOCUMENT_CONFIG, + ) + expect(html).not.toContain(' { + const html = sanitizeRichtext( + '

docs

', + MARKDOWN_DOCUMENT_CONFIG, + ) + expect(html).toContain('referrerpolicy="no-referrer"') + expect(html).toContain('loading="lazy"') + expect(html).toContain('rel="noopener noreferrer"') + expect(html).toContain('target="_blank"') + }) + + it('keeps the non-URI attributes the profile allows despite the strict URI regexp', () => { + const html = sanitizeRichtext( + '

logo

' + + '
c
', + MARKDOWN_DOCUMENT_CONFIG, + ) + expect(html).toContain('width="300"') + expect(html).toContain('height="80"') + expect(html).toContain('align="center"') + expect(html).toContain('width="50%"') + }) + + it('applies the external-images policy only to profiles that opt in', () => { + const permissive: SanitizerConfig = Object.fromEntries( + Object.entries(MARKDOWN_DOCUMENT_CONFIG).filter(([key]) => key !== 'ALLOWED_URI_REGEXP' && key !== '_externalImagesOnly'), + ) + const html = sanitizeRichtext('site image', permissive) + expect(html).toContain('src="/uploads/a.png"') + expect(html).not.toContain('referrerpolicy') + }) + + it('renders GFM markdown through the same sanitizer', () => { + const html = renderReadmeHtml('# Title\n\n[![npm](https://img.shields.io/x.svg)](https://npmjs.com/x)\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n') + expect(html).toContain('') + expect(html).not.toContain(' { expect(useEditorStore.getState().explorerPanelOpen).toBe(false) expect(useEditorStore.getState().isAgentOpen).toBe(false) expect(within(sidebar).getByTestId('dependencies-panel')).toBeDefined() - expect(within(sidebar).getByTestId('deps-section')).toBeDefined() + expect(within(sidebar).getByTestId('registry-panel')).toBeDefined() fireEvent.click(within(rail).getByRole('button', { name: /open ai assistant panel/i })) diff --git a/src/__tests__/panels/dependenciesPanel.test.tsx b/src/__tests__/panels/dependenciesPanel.test.tsx new file mode 100644 index 000000000..41027e084 --- /dev/null +++ b/src/__tests__/panels/dependenciesPanel.test.tsx @@ -0,0 +1,458 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import React from 'react' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { RegistryPanel } from '@site/panels/DependenciesPanel/RegistryPanel' +import { isDependencyLockInSync } from '@core/site-dependencies/lockStatus' +import { versionRange } from '@site/panels/DependenciesPanel/useInstalledDependencies' +import { formatCount } from '@site/panels/DependenciesPanel/format' +import { useEditorStore } from '@site/store/store' +import { AdminSessionContext } from '@admin/sessionContext' +import { ConfirmDeleteProvider } from '@admin/shared/dialogs/ConfirmDeleteDialog' +import type { CmsCurrentUser } from '@core/persistence' +import { normalizeSiteRuntimeConfig } from '@core/site-runtime' +import { makeSite } from '../fixtures' + +afterEach(cleanup) +const originalFetch = globalThis.fetch +afterEach(() => { + globalThis.fetch = originalFetch +}) + +const MOTION_HIT = { + name: 'motion', + version: '12.0.0', + description: 'Animation library', + publisher: 'mattgperry', + date: '2026-08-01T00:00:00.000Z', + weeklyDownloads: 20_000_000, + dependents: 1200, + score: { quality: 1, popularity: 1, maintenance: 1 }, + insecure: false, +} + +const DETAILS = { + name: 'motion', + description: 'Animation library', + latest: '12.0.0', + distTags: { latest: '12.0.0' }, + versions: [{ + version: '12.0.0', + date: '2026-08-01T00:00:00.000Z', + deprecated: null, + license: 'MIT', + dependencies: { 'framer-motion': '^12.0.0' }, + peerDependencies: {}, + unpackedSize: 1_000_000, + fileCount: 30, + esmEntry: { path: './dist/es/index.mjs', source: 'exports' }, + hasTypes: true, + }], + versionCount: 1, + readme: '# Motion\n\nHello from the README', + homepage: 'https://motion.dev', + repository: 'https://github.com/motiondivision/motion', + license: 'MIT', + maintainers: ['mattgperry'], + keywords: ['animation'], + modified: '2026-08-01T00:00:00.000Z', +} + +/** A package whose dist-tags list a prerelease before `latest`, as npm's JSON often does. */ +const TAGGED_DETAILS = { + ...DETAILS, + name: 'tagged', + latest: '5.0.0', + distTags: { dev: '1.0.0-dev.1', latest: '5.0.0', next: '6.0.0-beta.2' }, + versions: [ + { ...DETAILS.versions[0], version: '5.0.0' }, + { ...DETAILS.versions[0], version: '1.0.0-dev.1' }, + ], +} + +const LOCK_RESPONSE = { + dependencyLock: { + version: 1, + packages: { + 'canvas-confetti': { name: 'canvas-confetti', requested: '^1.9.3', version: '1.9.3', resolvedAt: 123 }, + }, + updatedAt: 123, + }, +} + +/** Button renders `aria-disabled` (not `disabled`) when it also carries a tooltip, so the reason stays hoverable. */ +function isDisabled(element: HTMLElement): boolean { + return (element as HTMLButtonElement).disabled || element.getAttribute('aria-disabled') === 'true' +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) +} + +const MANY_TOTAL = 45 +const PAGE_SIZE = 20 + +/** A paged result set for the query `many`: 45 packages named many-0 … many-44. */ +function manyPage(from: number) { + const hits = Array.from({ length: Math.max(0, Math.min(PAGE_SIZE, MANY_TOTAL - from)) }, (_, i) => ({ + ...MOTION_HIT, + name: `many-${from + i}`, + description: `Package number ${from + i}`, + })) + return { total: MANY_TOTAL, returned: hits.length, hits } +} + +/** + * Serve the registry proxy + resolve endpoints the panel talks to. Search + * knows `motion` (one hit) and `many` (45 hits, paged); `ghost-pkg` is a + * package the registry no longer answers for. + */ +function stubRegistryFetch(): string[] { + const requested: string[] = [] + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://localhost') + requested.push(url.pathname + url.search) + if (url.pathname.endsWith('/cms/registry')) { + return registryProfileFails + ? json({ error: 'Registry unavailable' }, 502) + : json({ host: 'registry.npmjs.org', publicNpm: true }) + } + if (url.pathname.endsWith('/registry/search')) { + const text = url.searchParams.get('q') ?? '' + if (text === 'many') return json(manyPage(Number(url.searchParams.get('from') ?? '0'))) + const hits = text.includes('motion') ? [MOTION_HIT] : [] + return json({ total: hits.length, returned: hits.length, hits }) + } + if (url.pathname.endsWith('/latest')) return json({ version: '1.9.4' }) + if (url.pathname.endsWith('/downloads')) return json({ daily: [1, 2, 3, 4, 5, 6, 7, 8], weekly: 35 }) + if (url.pathname.endsWith('/advisories')) return json({ advisories: [] }) + if (url.pathname.includes('/registry/packages/')) { + const name = decodeURIComponent(url.pathname.split('/').at(-1) ?? '') + if (name === 'ghost-pkg') return json({ error: 'Package not found' }, 404) + if (name === 'tagged') return json(TAGGED_DETAILS) + return json({ ...DETAILS, name }) + } + if (url.pathname.endsWith('/runtime/dependencies/resolve')) return json(LOCK_RESPONSE) + return json({ error: 'not stubbed' }, 404) + }) as typeof fetch + return requested +} + +let registryProfileFails = false + +function resetStore() { + const packageJson = { + dependencies: { 'canvas-confetti': '^1.9.3' }, + devDependencies: {}, + } + useEditorStore.setState({ + site: makeSite({ + packageJson, + runtime: normalizeSiteRuntimeConfig(undefined), + files: [{ + id: 'script-1', + path: 'src/scripts/celebrate.ts', + type: 'script', + content: `import confetti from 'canvas-confetti'\nimport { animate } from 'motion'`, + createdAt: 1, + updatedAt: 1, + }], + }), + packageJson, + siteRuntime: normalizeSiteRuntimeConfig(undefined), + activePageId: 'page-1', + _historyPast: [], + _historyFuture: [], + canUndo: false, + canRedo: false, + hasUnsavedChanges: false, + dependencyResolveStatus: 'idle', + dependencyResolveLockedCount: 0, + dependencyResolveError: null, + } as Parameters[0]) +} + +function lockCanvasConfetti(): void { + const lockedRuntime = normalizeSiteRuntimeConfig({ + dependencyLock: { + version: 1, + packages: { + 'canvas-confetti': { name: 'canvas-confetti', requested: '^1.9.3', version: '1.9.4', resolvedAt: 1 }, + }, + updatedAt: 1, + }, + }) + useEditorStore.setState({ + site: { ...useEditorStore.getState().site!, runtime: lockedRuntime }, + siteRuntime: lockedRuntime, + } as Parameters[0]) +} + +beforeEach(() => { + registryProfileFails = false + resetStore() + stubRegistryFetch() +}) + +describe('Dependencies panel: installed packages and runtime issues', () => { + it('marks packages imported by site scripts as in use', () => { + render() + const row = screen.getByTestId('dep-row-canvas-confetti') + expect(within(row).getByText('in use')).toBeDefined() + }) + + it('surfaces missing runtime imports and can add them as dependencies', () => { + render() + const issues = screen.getByLabelText('Runtime dependency issues') + expect(within(issues).getByText('motion')).toBeDefined() + expect(within(issues).getByText('missing from dependencies')).toBeDefined() + + fireEvent.click(within(issues).getByRole('button', { name: 'Add' })) + + expect(useEditorStore.getState().packageJson.dependencies.motion).toBe('*') + expect(useEditorStore.getState().site?.packageJson?.dependencies.motion).toBe('*') + }) + + it('shows the locked version on the installed row once the lock is resolved', () => { + lockCanvasConfetti() + render() + const row = screen.getByTestId('dep-row-canvas-confetti') + expect(within(row).getByTestId('dep-locked-canvas-confetti').textContent).toBe('1.9.4') + expect(screen.queryByRole('button', { name: 'Re-resolve' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Retry resolve' })).toBeNull() + }) + + it('exposes a manual Re-resolve button when the lock is out of sync', () => { + lockCanvasConfetti() + const packageJson = { dependencies: { 'canvas-confetti': '^1.9.3', motion: '*' }, devDependencies: {} } + useEditorStore.setState({ + site: { ...useEditorStore.getState().site!, packageJson }, + packageJson, + } as Parameters[0]) + render() + expect(screen.getByRole('button', { name: 'Re-resolve' })).toBeDefined() + }) + + it('resolves runtime dependencies into the lock via the manual button', async () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Re-resolve' })) + expect(await screen.findByText('1 locked')).toBeDefined() + expect(useEditorStore.getState().siteRuntime.dependencyLock.packages['canvas-confetti']?.version).toBe('1.9.3') + }) + + it('lists dev dependencies in their own group', () => { + const packageJson = { dependencies: { 'canvas-confetti': '^1.9.3' }, devDependencies: { typescript: '^5' } } + useEditorStore.setState({ + site: { ...useEditorStore.getState().site!, packageJson }, + packageJson, + } as Parameters[0]) + render() + expect(screen.getByText('Dev dependencies')).toBeDefined() + expect(screen.getByTestId('dep-row-typescript')).toBeDefined() + }) +}) + +describe('Dependencies panel: registry browsing', () => { + it('searches the registry, opens a package page, and installs the picked version', async () => { + render() + fireEvent.change(screen.getByTestId('registry-search'), { target: { value: 'motion' } }) + + const result = await screen.findByTestId('registry-result-motion', {}, { timeout: 3000 }) + expect(within(result).getByText('Animation library')).toBeDefined() + fireEvent.click(result) + + const detail = await screen.findByTestId('package-detail-motion') + expect(await within(detail).findByText('Hello from the README')).toBeDefined() + expect(within(detail).getByText('ESM')).toBeDefined() + expect(within(detail).getByText('TS')).toBeDefined() + + fireEvent.click(await within(detail).findByTestId('dependency-install-motion')) + + expect(useEditorStore.getState().packageJson.dependencies.motion).toBe('^12.0.0') + expect(await within(detail).findByTestId('dependency-installed-motion')).toBeDefined() + }) + + it('offers to open a typed package by exact name when the index does not list it', async () => { + render() + fireEvent.change(screen.getByTestId('registry-search'), { target: { value: 'left-pad' } }) + + const open = await screen.findByTestId('registry-open-left-pad', {}, { timeout: 3000 }) + fireEvent.click(open) + const detail = await screen.findByTestId('package-detail-left-pad') + expect(await within(detail).findByTestId('dependency-install-left-pad')).toBeDefined() + }) + + it('opens the typed name on Enter instead of whatever the previous query listed', async () => { + render() + const search = screen.getByTestId('registry-search') + fireEvent.change(search, { target: { value: 'motion' } }) + await screen.findByTestId('registry-result-motion', {}, { timeout: 3000 }) + + fireEvent.change(search, { target: { value: 'three' } }) + fireEvent.keyDown(search, { key: 'Enter' }) + + expect(await screen.findByTestId('package-detail-three')).toBeDefined() + expect(screen.queryByTestId('package-detail-motion')).toBeNull() + }) + + it('opens an installed package from its row and always confirms before removing one that is in use', async () => { + render( + + + , + ) + fireEvent.click(screen.getByTestId('dep-row-canvas-confetti')) + const detail = await screen.findByTestId('package-detail-canvas-confetti') + + fireEvent.click(await within(detail).findByTestId('dependency-remove-canvas-confetti')) + const dialog = screen.getByRole('alertdialog', { name: 'Remove canvas-confetti?' }) + expect(dialog.textContent).toContain('Used by scripts: celebrate.ts') + expect(useEditorStore.getState().packageJson.dependencies['canvas-confetti']).toBe('^1.9.3') + + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('alertdialog')).toBeNull() + + fireEvent.click(within(detail).getByTestId('dependency-remove-canvas-confetti')) + fireEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })) + expect(useEditorStore.getState().packageJson.dependencies['canvas-confetti']).toBeUndefined() + }) + + it('still lets an installed package be removed when the registry no longer answers for it', async () => { + useEditorStore.setState({ + packageJson: { dependencies: { 'canvas-confetti': '^1.9.3', 'ghost-pkg': '*' }, devDependencies: {} }, + } as Parameters[0]) + render() + fireEvent.click(screen.getByTestId('dep-row-ghost-pkg')) + const detail = await screen.findByTestId('package-detail-ghost-pkg') + + expect((await within(detail).findByRole('alert')).textContent).toContain('Package not found') + fireEvent.click(within(detail).getByTestId('dependency-remove-ghost-pkg')) + expect(useEditorStore.getState().packageJson.dependencies['ghost-pkg']).toBeUndefined() + }) + + it('starts a query on its first page again after paging it and searching for something else', async () => { + render() + const search = screen.getByTestId('registry-search') + fireEvent.change(search, { target: { value: 'many' } }) + await screen.findByTestId('registry-result-many-0', {}, { timeout: 3000 }) + fireEvent.click(screen.getByRole('button', { name: 'Show more results' })) + await screen.findByTestId('registry-result-many-20', {}, { timeout: 3000 }) + + fireEvent.change(search, { target: { value: 'motion' } }) + await screen.findByTestId('registry-result-motion', {}, { timeout: 3000 }) + + fireEvent.change(search, { target: { value: 'many' } }) + await screen.findByTestId('registry-result-many-0', {}, { timeout: 3000 }) + expect(screen.getByTestId('registry-result-many-19')).toBeDefined() + expect(screen.queryByTestId('registry-result-many-20')).toBeNull() + expect(screen.getByRole('button', { name: 'Show more results' })).toBeDefined() + }) + + it('preselects the latest version, not whichever dist-tag the registry lists first', async () => { + render() + fireEvent.change(screen.getByTestId('registry-search'), { target: { value: 'tagged' } }) + fireEvent.click(await screen.findByTestId('registry-open-tagged', {}, { timeout: 3000 })) + const detail = await screen.findByTestId('package-detail-tagged') + + const picker = within(detail).getByLabelText('Version to install') + expect(picker.value).toBe('5.0.0') + fireEvent.click(within(detail).getByTestId('dependency-install-tagged')) + expect(useEditorStore.getState().packageJson.dependencies.tagged).toBe('^5.0.0') + }) + + it('can still declare a package when the registry cannot describe it', async () => { + render() + fireEvent.change(screen.getByTestId('registry-search'), { target: { value: 'ghost-pkg' } }) + fireEvent.click(await screen.findByTestId('registry-open-ghost-pkg', {}, { timeout: 3000 })) + const detail = await screen.findByTestId('package-detail-ghost-pkg') + + expect((await within(detail).findByRole('alert')).textContent).toContain('Package not found') + fireEvent.click(within(detail).getByTestId('dependency-install-ghost-pkg')) + expect(useEditorStore.getState().packageJson.dependencies['ghost-pkg']).toBe('*') + }) + + it('keeps npm-only sections hidden but says so when the registry profile fails to load', async () => { + registryProfileFails = true + render() + expect((await screen.findByRole('alert')).textContent).toContain('Registry unavailable') + expect(screen.queryByText('Popular for sites')).toBeNull() + }) + + it('does not mistake prototype properties for installed packages', async () => { + render() + fireEvent.change(screen.getByTestId('registry-search'), { target: { value: 'constructor' } }) + fireEvent.click(await screen.findByTestId('registry-open-constructor', {}, { timeout: 3000 })) + const detail = await screen.findByTestId('package-detail-constructor') + expect(await within(detail).findByTestId('dependency-install-constructor')).toBeDefined() + expect(within(detail).queryByTestId('dependency-installed-constructor')).toBeNull() + }) + + it('shows a failed resolve with a retry on the package page where the install happened', async () => { + useEditorStore.setState({ + dependencyResolveStatus: 'error', + dependencyResolveError: 'Registry responded with 503', + } as Parameters[0]) + render() + fireEvent.click(screen.getByTestId('dep-row-canvas-confetti')) + const detail = await screen.findByTestId('package-detail-canvas-confetti') + + const installed = await within(detail).findByTestId('dependency-installed-canvas-confetti') + expect(within(installed).getByRole('alert').textContent).toContain('Registry responded with 503') + expect(within(installed).getByTestId('dependency-retry-canvas-confetti')).toBeDefined() + }) + + it('keeps install and remove disabled without the runtime.dependencies capability', async () => { + const viewer = { id: 'u1', capabilities: ['site.read'] } as unknown as CmsCurrentUser + render( + {} }}> + + , + ) + const issues = screen.getByLabelText('Runtime dependency issues') + expect(isDisabled(within(issues).getByRole('button', { name: 'Add' }))).toBe(true) + + fireEvent.click(screen.getByTestId('dep-row-canvas-confetti')) + const detail = await screen.findByTestId('package-detail-canvas-confetti') + expect(isDisabled(await within(detail).findByTestId('dependency-remove-canvas-confetti'))).toBe(true) + }) +}) + +describe('formatting helpers', () => { + it('pins exact picks with a caret and keeps latest open', () => { + expect(versionRange('12.0.0')).toBe('^12.0.0') + expect(versionRange('latest')).toBe('*') + expect(versionRange('')).toBe('*') + }) + + it('formats counts compactly', () => { + expect(formatCount(15_193_062)).toBe('15.2M') + expect(formatCount(4_300)).toBe('4.3K') + expect(formatCount(120_300)).toBe('120K') + expect(formatCount(0)).toBe('0') + }) +}) + +describe('isDependencyLockInSync', () => { + const locked = { 'canvas-confetti': { name: 'canvas-confetti', requested: '^1.9.3', version: '1.9.4', resolvedAt: 1 } } + + it('is in sync with nothing requested, even when the lock still lists packages', () => { + expect(isDependencyLockInSync({ dependencies: {}, devDependencies: {} }, {})).toBe(true) + expect(isDependencyLockInSync({ dependencies: {}, devDependencies: {} }, locked)).toBe(true) + }) + + it('is out of sync while a requested package has no lock entry', () => { + expect(isDependencyLockInSync({ dependencies: { 'canvas-confetti': '*' }, devDependencies: {} }, {})).toBe(false) + expect(isDependencyLockInSync({ dependencies: { 'canvas-confetti': '^1.9.3', motion: '*' }, devDependencies: {} }, locked)).toBe(false) + }) + + it('is out of sync when a locked request changed or a lock entry lost its request', () => { + expect(isDependencyLockInSync({ dependencies: { 'canvas-confetti': '^2.0.0' }, devDependencies: {} }, locked)).toBe(false) + expect(isDependencyLockInSync({ dependencies: { motion: '*' }, devDependencies: {} }, { + ...locked, + motion: { name: 'motion', requested: '*', version: '12.0.0', resolvedAt: 1 }, + })).toBe(false) + }) + + it('is in sync when every requested package is locked at the same range', () => { + expect(isDependencyLockInSync({ dependencies: { 'canvas-confetti': '^1.9.3' }, devDependencies: {} }, locked)).toBe(true) + }) +}) diff --git a/src/__tests__/panels/depsSectionRuntime.test.tsx b/src/__tests__/panels/depsSectionRuntime.test.tsx deleted file mode 100644 index d0c64067c..000000000 --- a/src/__tests__/panels/depsSectionRuntime.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test' -import React from 'react' -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import { DepsSection } from '@site/panels/DependenciesPanel/DepsSection' -import { evaluateDependencyLockStatus } from '@site/panels/DependenciesPanel/lockStatus' -import { useEditorStore } from '@site/store/store' -import { makeSite } from '../fixtures' -import { normalizeSiteRuntimeConfig } from '@core/site-runtime' - -afterEach(cleanup) -const originalFetch = globalThis.fetch - -afterEach(() => { - globalThis.fetch = originalFetch -}) - -function resetStore() { - const packageJson = { - dependencies: { 'canvas-confetti': '^1.9.3' }, - devDependencies: {}, - } - useEditorStore.setState({ - site: makeSite({ - packageJson, - runtime: normalizeSiteRuntimeConfig(undefined), - files: [{ - id: 'script-1', - path: 'src/scripts/celebrate.ts', - type: 'script', - content: `import confetti from 'canvas-confetti'\nimport { animate } from 'motion'`, - createdAt: 1, - updatedAt: 1, - }], - }), - packageJson, - siteRuntime: normalizeSiteRuntimeConfig(undefined), - activePageId: 'page-1', - _historyPast: [], - _historyFuture: [], - canUndo: false, - canRedo: false, - hasUnsavedChanges: false, - // Reset auto-resolve transient state so prior tests in the same suite - // (or in `useAutoResolveDependencies.test.tsx`) don't leak a "resolved" - // banner / counter into the DepsSection render under test. - dependencyResolveStatus: 'idle', - dependencyResolveLockedCount: 0, - dependencyResolveError: null, - } as Parameters[0]) -} - -beforeEach(resetStore) - -describe('DepsSection runtime script dependency usage', () => { - it('marks packages imported by site scripts as in use', () => { - render() - - const row = screen.getByTestId('dep-row-canvas-confetti') - expect(within(row).getByText('in use')).toBeDefined() - expect(within(row).getByTitle(/scripts: celebrate\.ts/)).toBeDefined() - }) - - it('surfaces missing runtime imports and can add them as dependencies', () => { - render() - - const issues = screen.getByLabelText('Runtime dependency issues') - expect(within(issues).getByText('motion')).toBeDefined() - expect(within(issues).getByText('missing from dependencies')).toBeDefined() - - fireEvent.click(within(issues).getByRole('button', { name: 'Add' })) - - expect(useEditorStore.getState().packageJson.dependencies.motion).toBe('*') - expect(useEditorStore.getState().site?.packageJson?.dependencies.motion).toBe('*') - }) - - it('shows the locked version next to the requested range when the lock has been resolved', () => { - const lockedRuntime = normalizeSiteRuntimeConfig({ - dependencyLock: { - version: 1, - packages: { - 'canvas-confetti': { - name: 'canvas-confetti', - requested: '^1.9.3', - version: '1.9.4', - resolvedAt: 1, - }, - }, - updatedAt: 1, - }, - }) - useEditorStore.setState({ - site: { ...useEditorStore.getState().site!, runtime: lockedRuntime }, - siteRuntime: lockedRuntime, - } as Parameters[0]) - - render() - - const row = screen.getByTestId('dep-row-canvas-confetti') - expect(within(row).getByTitle('Locked at 1.9.4')).toBeDefined() - // The lock matches the requested range — no manual re-resolve UI should - // appear (auto-resolve has nothing to do, and the panel stays tidy). - expect(screen.queryByRole('button', { name: 'Re-resolve' })).toBeNull() - expect(screen.queryByRole('button', { name: 'Retry resolve' })).toBeNull() - }) - - it('exposes a manual Re-resolve button when packageJson has un-resolved or changed packages', () => { - const lockedRuntime = normalizeSiteRuntimeConfig({ - dependencyLock: { - version: 1, - packages: { - 'canvas-confetti': { - name: 'canvas-confetti', - requested: '^1.9.3', - version: '1.9.4', - resolvedAt: 1, - }, - }, - updatedAt: 1, - }, - }) - const packageJson = { - dependencies: { 'canvas-confetti': '^1.9.3', motion: '*' }, - devDependencies: {}, - } - useEditorStore.setState({ - site: { - ...useEditorStore.getState().site!, - packageJson, - runtime: lockedRuntime, - }, - packageJson, - siteRuntime: lockedRuntime, - } as Parameters[0]) - - render() - - // The auto-resolve hook handles the common case in the editor shell; - // the panel still surfaces a manual escape hatch when the lock is out - // of sync. - expect(screen.getByRole('button', { name: 'Re-resolve' })).toBeDefined() - }) - - it('resolves runtime dependencies into the site dependency lock via the manual button', async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ - dependencyLock: { - version: 1, - packages: { - 'canvas-confetti': { - name: 'canvas-confetti', - requested: '^1.9.3', - version: '1.9.3', - resolvedAt: 123, - }, - }, - updatedAt: 123, - }, - }), { status: 200 })) as typeof fetch - - render() - - fireEvent.click(screen.getByRole('button', { name: 'Re-resolve' })) - expect(await screen.findByText('1 locked')).toBeDefined() - expect(useEditorStore.getState().siteRuntime.dependencyLock.packages['canvas-confetti']?.version).toBe('1.9.3') - expect(useEditorStore.getState().site?.runtime?.dependencyLock.packages['canvas-confetti']?.version).toBe('1.9.3') - }) -}) - -describe('evaluateDependencyLockStatus', () => { - it('returns in-sync when there are no requested packages', () => { - expect( - evaluateDependencyLockStatus({ dependencies: {}, devDependencies: {} }, {}), - ).toEqual({ kind: 'in-sync' }) - }) - - it('returns unresolved when packages are requested but the lock is empty', () => { - expect( - evaluateDependencyLockStatus( - { dependencies: { 'canvas-confetti': '*' }, devDependencies: {} }, - {}, - ), - ).toEqual({ kind: 'unresolved', missing: ['canvas-confetti'] }) - }) - - it('returns stale when a previously-resolved request changed', () => { - const status = evaluateDependencyLockStatus( - { dependencies: { 'canvas-confetti': '^2.0.0' }, devDependencies: {} }, - { - 'canvas-confetti': { - name: 'canvas-confetti', - requested: '^1.9.3', - version: '1.9.4', - resolvedAt: 1, - }, - }, - ) - expect(status.kind).toBe('stale') - if (status.kind === 'stale') { - expect(status.mismatched).toEqual(['canvas-confetti']) - expect(status.missing).toEqual([]) - expect(status.orphan).toEqual([]) - } - }) - - it('flags packages present in the lock but no longer in packageJson as orphans', () => { - const status = evaluateDependencyLockStatus( - { dependencies: {}, devDependencies: {} }, - { - 'canvas-confetti': { - name: 'canvas-confetti', - requested: '^1.9.3', - version: '1.9.4', - resolvedAt: 1, - }, - }, - ) - expect(status).toEqual({ kind: 'in-sync' }) - }) - - it('returns stale with both new and changed sets when both occur', () => { - const status = evaluateDependencyLockStatus( - { - dependencies: { 'canvas-confetti': '^2.0.0', motion: '*' }, - devDependencies: {}, - }, - { - 'canvas-confetti': { - name: 'canvas-confetti', - requested: '^1.9.3', - version: '1.9.4', - resolvedAt: 1, - }, - }, - ) - expect(status.kind).toBe('stale') - if (status.kind === 'stale') { - expect(status.missing).toEqual(['motion']) - expect(status.mismatched).toEqual(['canvas-confetti']) - } - }) - - it('returns in-sync when every requested package is locked at the same range', () => { - expect( - evaluateDependencyLockStatus( - { dependencies: { 'canvas-confetti': '^1.9.3' }, devDependencies: {} }, - { - 'canvas-confetti': { - name: 'canvas-confetti', - requested: '^1.9.3', - version: '1.9.4', - resolvedAt: 1, - }, - }, - ), - ).toEqual({ kind: 'in-sync' }) - }) -}) diff --git a/src/__tests__/server/serverConfig.test.ts b/src/__tests__/server/serverConfig.test.ts index 1c1850c5d..aa8642911 100644 --- a/src/__tests__/server/serverConfig.test.ts +++ b/src/__tests__/server/serverConfig.test.ts @@ -128,10 +128,11 @@ describe('readServerConfig', () => { staticDir: './dist', trustedProxyCidrs: [], publicOrigins: [], + npmRegistryUrl: 'https://registry.npmjs.org', }) }) - it('reads runtime paths, port, trusted proxies, and public origins from env', () => { + it('reads runtime paths, port, trusted proxies, public origins, and the registry from env', () => { expect( readServerConfig({ PORT: '4321', @@ -144,6 +145,7 @@ describe('readServerConfig', () => { PUBLIC_ORIGIN: 'https://CMS.example.com/, http://localhost:5173', RENDER_EXTERNAL_URL: 'https://ignored.onrender.com', RAILWAY_PUBLIC_DOMAIN: 'ignored.up.railway.app', + NPM_REGISTRY_URL: 'https://npm.example.com/registry/', }), ).toEqual({ port: 4321, @@ -154,6 +156,7 @@ describe('readServerConfig', () => { staticDir: '/srv/instatic/dist', trustedProxyCidrs: ['10.0.0.0/8', '192.168.0.0/16'], publicOrigins: ['https://cms.example.com', 'http://localhost:5173'], + npmRegistryUrl: 'https://npm.example.com/registry', }) }) }) diff --git a/src/admin/access.ts b/src/admin/access.ts index 6228521ab..5193485bf 100644 --- a/src/admin/access.ts +++ b/src/admin/access.ts @@ -39,12 +39,6 @@ const PLUGIN_READ_CAPABILITIES: CoreCapability[] = [ 'plugins.lifecycle', ] -const RUNTIME_STORAGE_CAPABILITIES: CoreCapability[] = [ - 'runtime.dependencies', - 'storage.elect', - 'storage.migrate', -] - export function hasCapability(user: CmsCurrentUser | null, capability: CoreCapability): boolean { return Boolean(user?.capabilities.includes(capability)) } @@ -88,6 +82,16 @@ export function canEditStyle(user: CmsCurrentUser | null): boolean { return hasCapability(user, 'site.style.edit') } +/** + * Install, remove and re-declare the site's npm dependencies (Dependencies + * panel, runtime-import fixes). Browsing the registry only needs `site.read`. + * Null session = unrestricted, like every other editor gate here. + */ +export function canManageRuntimeDependencies(user: CmsCurrentUser | null): boolean { + if (!user) return true + return hasCapability(user, 'runtime.dependencies') +} + /** Caller can save the draft site in any form (structure + content + style). */ export function canSaveDraftSite(user: CmsCurrentUser | null): boolean { if (!user) return true @@ -328,9 +332,3 @@ export function workspacePath(workspace: AdminWorkspace): string { return '/admin/account' } } - -// Reference unused imports so the linter doesn't strip them when not consumed -// downstream yet (RUNTIME_STORAGE_CAPABILITIES is here for symmetry — the -// runtime workspace doesn't currently have its own canAccess gate because -// there is no dedicated runtime workspace; storage admin lives under media). -void RUNTIME_STORAGE_CAPABILITIES diff --git a/src/admin/pages/media/utils/formatBytes.ts b/src/admin/lib/formatBytes.ts similarity index 56% rename from src/admin/pages/media/utils/formatBytes.ts rename to src/admin/lib/formatBytes.ts index 4548cc0b7..b3b57cd71 100644 --- a/src/admin/pages/media/utils/formatBytes.ts +++ b/src/admin/lib/formatBytes.ts @@ -1,10 +1,10 @@ /** - * Human-readable file size for the Media workspace. + * Human-readable byte size for admin surfaces: media assets, the upload + * queue, package sizes in the Dependencies panel. * - * Binary units (1 KB = 1024 B). KB/MB show one decimal, GB shows two — the - * tone used across the media asset viewer, canvas tiles, upload queue, and - * replace-file dialog. Other surfaces with different needs (estimate ranges, - * MB-capped font sizes) keep their own bespoke formatters intentionally. + * Binary units (1 KB = 1024 B). KB/MB show one decimal, GB shows two. + * Surfaces with different needs (estimate ranges, MB-capped font sizes) + * keep their own bespoke formatters intentionally. */ export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B` diff --git a/src/admin/pages/media/components/MediaCanvas/MediaCanvasItems.tsx b/src/admin/pages/media/components/MediaCanvas/MediaCanvasItems.tsx index 94ad90d72..9596f8e28 100644 --- a/src/admin/pages/media/components/MediaCanvas/MediaCanvasItems.tsx +++ b/src/admin/pages/media/components/MediaCanvas/MediaCanvasItems.tsx @@ -15,7 +15,7 @@ import type { CmsMediaAsset, CmsMediaFolder } from '@core/persistence/cmsMedia' import type { FolderSelection } from '../../hooks/useMediaWorkspace' import { bucketForMime } from '../../utils/filters' import { blurHashToDataUrl, pickVariantUrl } from '../../utils/variants' -import { formatBytes } from '../../utils/formatBytes' +import { formatBytes } from '@admin/lib/formatBytes' import styles from './MediaCanvas.module.css' export interface ParentFolderEntry { diff --git a/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx b/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx index 27b3a6a3b..3d7583777 100644 --- a/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx +++ b/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx @@ -43,7 +43,7 @@ import { useDebouncedSave } from '../../hooks/useDebouncedSave' import { TagEditor } from '../TagEditor/TagEditor' import { ReplaceFileDialog } from '../ReplaceFileDialog/ReplaceFileDialog' import { ViewerBody } from '../viewers/ViewerBody' -import { formatBytes } from '../../utils/formatBytes' +import { formatBytes } from '@admin/lib/formatBytes' import styles from './MediaViewerWindow.module.css' /** diff --git a/src/admin/pages/media/components/ReplaceFileDialog/ReplaceFileDialog.tsx b/src/admin/pages/media/components/ReplaceFileDialog/ReplaceFileDialog.tsx index c4752be01..ea651db48 100644 --- a/src/admin/pages/media/components/ReplaceFileDialog/ReplaceFileDialog.tsx +++ b/src/admin/pages/media/components/ReplaceFileDialog/ReplaceFileDialog.tsx @@ -12,7 +12,7 @@ import { Dialog } from '@ui/components/Dialog' import { FileUpload } from '@ui/components/FileUpload' import { UploadIcon } from 'pixel-art-icons/icons/upload' import type { CmsMediaAsset } from '@core/persistence/cmsMedia' -import { formatBytes } from '../../utils/formatBytes' +import { formatBytes } from '@admin/lib/formatBytes' import styles from './ReplaceFileDialog.module.css' import { getErrorMessage } from '@core/utils/errorMessage' diff --git a/src/admin/pages/media/components/UploadQueueWindow/UploadQueueWindow.tsx b/src/admin/pages/media/components/UploadQueueWindow/UploadQueueWindow.tsx index 8703ed9bb..7066fd950 100644 --- a/src/admin/pages/media/components/UploadQueueWindow/UploadQueueWindow.tsx +++ b/src/admin/pages/media/components/UploadQueueWindow/UploadQueueWindow.tsx @@ -14,7 +14,7 @@ import { ReloadIcon } from 'pixel-art-icons/icons/reload' import { UploadIcon } from 'pixel-art-icons/icons/upload' import { FloatingWindow } from '@admin/shared/FloatingWindow' import type { UploadItem, UseUploadQueueResult } from '../../hooks/useUploadQueue' -import { formatBytes } from '../../utils/formatBytes' +import { formatBytes } from '@admin/lib/formatBytes' import styles from './UploadQueueWindow.module.css' interface UploadQueueWindowProps { diff --git a/src/admin/pages/site/hooks/useAutoResolveDependencies.ts b/src/admin/pages/site/hooks/useAutoResolveDependencies.ts index aaf3e61c0..7e3801fac 100644 --- a/src/admin/pages/site/hooks/useAutoResolveDependencies.ts +++ b/src/admin/pages/site/hooks/useAutoResolveDependencies.ts @@ -16,16 +16,19 @@ * plugins would see "TypeError: Failed to resolve module specifier". * * The resolution itself is a store action (`resolveDependencyLock`) so the - * Dependencies Panel reads the same status the auto-resolve produces. + * Dependencies panel (`useInstalledDependencies`) reads the same status the + * auto-resolve produces and can offer a retry. * Failures surface through `dependencyResolveStatus = 'error'` but don't * throw — a network blip shouldn't crash the editor. * - * Mounted from `SitePage` so the loop runs whenever the visual editor is - * open, regardless of whether the Dependencies Panel itself is visible. + * Mounted from `AdminCanvasEditorBody` so the loop runs whenever the visual + * editor is open, regardless of whether the Dependencies panel is visible. + * Installing from the panel writes `packageJson` and relies on this hook to + * turn that into a resolve; keep them mounted together. */ import { useEffect, useRef } from 'react' import { useEditorStore } from '@site/store/store' -import { evaluateDependencyLockStatus } from '@site/panels/DependenciesPanel/lockStatus' +import { isDependencyLockInSync } from '@core/site-dependencies/lockStatus' const AUTO_RESOLVE_DEBOUNCE_MS = 600 @@ -52,7 +55,7 @@ export function useAutoResolveDependencies({ // otherwise fire one no-op resolve on mount. if (!site) return - const status = evaluateDependencyLockStatus(packageJson, lockedPackages) + const lockInSync = isDependencyLockInSync(packageJson, lockedPackages) const lockHasPackages = Object.keys(lockedPackages).length > 0 // Every locked package needs a root entry in the importmap — `name` → // its entry-file URL. Missing entries mean the iframe sandbox would @@ -61,7 +64,7 @@ export function useAutoResolveDependencies({ !packageImportmap || Object.keys(lockedPackages).some((name) => !packageImportmap.imports[name]) ) - if (status.kind === 'in-sync' && !importmapMissing) return + if (lockInSync && !importmapMissing) return // Don't pile on top of an in-flight resolve — the action's own // concurrency guard short-circuits, but skipping the timer avoids the @@ -72,7 +75,7 @@ export function useAutoResolveDependencies({ timerRef.current = setTimeout(() => { timerRef.current = null // Swallow rejections — the action stores the error on the slice for - // DepsSection to display. A thrown promise here would surface as an + // the Dependencies panel to display. A thrown promise here would surface as an // unhandled rejection in the console. resolveDependencyLock().catch(() => {}) }, debounceMs) diff --git a/src/admin/pages/site/panels/DependenciesPanel/DependenciesPanel.tsx b/src/admin/pages/site/panels/DependenciesPanel/DependenciesPanel.tsx index 5c88a5933..b55026869 100644 --- a/src/admin/pages/site/panels/DependenciesPanel/DependenciesPanel.tsx +++ b/src/admin/pages/site/panels/DependenciesPanel/DependenciesPanel.tsx @@ -5,7 +5,7 @@ import { useAutoFocusPanel, type DockablePanelProps, } from '@admin/shared/Panel' -import { DepsSection } from './DepsSection' +import { RegistryPanel } from './RegistryPanel' export function DependenciesPanel({ mode = 'docked', @@ -31,8 +31,9 @@ export function DependenciesPanel({ dragHandleProps={dragHandleProps} onToggleMode={onToggleMode} dockLocation="left sidebar" + body="bare" > - + ) } diff --git a/src/admin/pages/site/panels/DependenciesPanel/DepsSection.module.css b/src/admin/pages/site/panels/DependenciesPanel/DepsSection.module.css deleted file mode 100644 index 5114d6246..000000000 --- a/src/admin/pages/site/panels/DependenciesPanel/DepsSection.module.css +++ /dev/null @@ -1,238 +0,0 @@ -/* DepsSection — dependency management content body for DependenciesPanel. - * - * Mounted inside the shared `Panel`'s padded body, which already supplies - * the canonical 8px outer padding + 10px gap. This module owns only the - * deps-specific layout: the package list, the bottom add form, and the - * per-package row chrome. */ - -/* ── Section body ─────────────────────────────────────────────────────────── */ -.body { - display: flex; - flex: 1; - min-height: 0; - flex-direction: column; -} - -.srLiveRegion { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; -} - -/* ── Package list ────────────────────────────────────────────────────────── */ -.packageList { - flex: 1; - overflow-y: auto; - min-height: 0; -} - -.sectionLabel { - padding: var(--space-l) 0 var(--space-s); - margin: 0; - overflow: hidden; - color: var(--text-subtle); - font-size: var(--text-xs); - font-weight: 700; - letter-spacing: 0; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* ── Runtime dependency diagnostics ─────────────────────────────────────── */ -.runtimeIssues { - display: flex; - flex-direction: column; - gap: var(--space-3xs); - margin-bottom: var(--space-xs); - padding: var(--space-xs); - border: 1px solid color-mix(in srgb, var(--warning) 22%, transparent); - border-radius: 6px; - background: color-mix(in srgb, var(--warning) 8%, transparent); -} - -.runtimeIssue { - display: flex; - align-items: center; - gap: var(--space-xs); - min-height: 24px; -} - -.runtimeIssueText { - display: flex; - align-items: center; - gap: var(--space-2xs); - min-width: 0; - flex: 1; - color: var(--text-muted); - font-size: var(--text-2xs); - line-height: 1.3; -} - -.runtimeIssuePackage { - overflow: hidden; - color: var(--warning-text); - font-family: monospace; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* ── Empty / no-results state ────────────────────────────────────────────── */ -.emptyMsg { - padding: var(--space-2xl) var(--space-3xs); - color: var(--text-disabled); - font-size: var(--text-xs); - text-align: center; -} - -/* ── Add package form (pinned to bottom of the body) ─────────────────────── */ -.addForm { - padding-top: var(--space-s); - margin-top: var(--space-3xs); - border-top: 1px solid var(--overlay-10); - flex-shrink: 0; -} - -.resolveRow { - display: flex; - align-items: center; - gap: var(--space-xs); - min-height: 26px; - margin-bottom: var(--space-2xs); -} - -.resolveStatus { - min-width: 0; - overflow: hidden; - color: var(--text-disabled); - font-size: var(--text-2xs); - line-height: 1.3; - text-overflow: ellipsis; - white-space: nowrap; -} - -.resolveStatus[data-status='resolved'] { - color: var(--success-text-muted); -} - -.resolveStatus[data-status='error'] { - color: var(--danger-light); -} - -.addRow { - display: flex; - gap: var(--space-3xs); - align-items: flex-start; -} - -.addInputArea { - flex: 1; -} - -.addInputWrapper { - display: flex; - align-items: center; - gap: var(--space-3xs); -} - -.addInput { - flex: 1; -} - -.addError { - font-size: var(--text-2xs); - color: var(--danger); - padding: var(--space-4xs) var(--space-4xs) 0; -} - -.devToggle { - display: flex; - align-items: center; - gap: var(--space-2xs); - margin-top: var(--space-3xs); - cursor: pointer; - user-select: none; -} - -.devLabel { - font-size: var(--text-2xs); - color: var(--text-disabled); -} - -/* ── DepRow — single dependency row ─────────────────────────────────────── */ -.depRow { - display: flex; - align-items: center; - justify-content: space-between; - height: 26px; - padding: 0 var(--space-s); - gap: var(--space-3xs); - border-radius: 4px; - margin: var(--space-px) 0; - cursor: default; -} - -.depRowIcon { - display: flex; - color: var(--text-disabled); - flex-shrink: 0; -} - -.depName { - font-size: var(--text-xs); - color: var(--text); - font-family: monospace; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; -} - -.depVersion { - font-size: var(--text-2xs); - color: var(--text-disabled); - font-family: monospace; - flex-shrink: 0; -} - -.depLockedVersion { - color: var(--success-text-muted); -} - -.depUsage { - flex-shrink: 0; - padding: var(--space-px) var(--space-2xs); - border: 1px solid color-mix(in srgb, var(--info-text) 22%, transparent); - border-radius: 999px; - background: color-mix(in srgb, var(--info-text) 12%, transparent); - color: var(--info-text); - font-size: var(--text-3xs); - font-weight: 700; - letter-spacing: 0.02em; -} - -/* ── Inline remove confirmation ──────────────────────────────────────────── */ -.depRowConfirm { - display: flex; - align-items: center; - gap: var(--space-xs); - padding: var(--space-3xs) var(--space-s); - border-radius: 4px; - margin: var(--space-px) 0; - background: color-mix(in srgb, var(--danger) 8%, transparent); - border: 1px solid var(--danger-20); -} - -.depConfirmText { - font-size: var(--text-2xs); - color: var(--danger-light); - flex: 1; - line-height: 1.35; -} - -.depConfirmDetail { - color: var(--danger-text); -} diff --git a/src/admin/pages/site/panels/DependenciesPanel/DepsSection.tsx b/src/admin/pages/site/panels/DependenciesPanel/DepsSection.tsx deleted file mode 100644 index 6917386b9..000000000 --- a/src/admin/pages/site/panels/DependenciesPanel/DepsSection.tsx +++ /dev/null @@ -1,608 +0,0 @@ -/** - * DepsSection — dependency management content body. - * - * Mounted as the sole child of the shared `Panel`'s padded body in - * `DependenciesPanel`. Owns no panel chrome — the outer `