Skip to content

Commit 8f5103a

Browse files
authored
fix(import): install npm deps from module scripts (CoreBunch#143)
## What changed - Super Import rewrites known npm ESM CDN imports in module scripts to bare package imports and records matching npm dependency requests. - Imported script dependencies commit into site.packageJson.dependencies in the same undoable import transaction. - site_write_code_asset accepts a dependencies map so AI agents can add module-script package imports and package manifest entries in one tool call. - Site-agent prompt and docs now direct agents to use bare npm imports plus dependencies instead of npm CDN URLs. ## Verification - bun run lint - bun run build - bun test
1 parent ca70d6b commit 8f5103a

17 files changed

Lines changed: 413 additions & 98 deletions

File tree

docs/features/agent.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,12 +392,26 @@ Scripts and user stylesheets live in `site.files[]`; runtime targeting and loadi
392392
|------------------------|--------------------------------------------|-----------------------------------------|-------------------------------------------------------|
393393
| `list_code_assets` | `{ type?: 'script' \| 'style' }` | `{ assets }` | List runtime code assets with file ids, paths, full-content hashes, sizes, timestamps, and runtime config |
394394
| `read_code_asset` | `{ fileId? \| path?, part?, maxChars? }` | `{ fileId, path, type, content, hash, runtime, pageInfo }` | Read an exact script/stylesheet content slice. The `hash` is for the full file; page through with `pageInfo.nextPart` |
395-
| `write_code_asset` | `{ path, type, content, runtime? }` | asset summary + `{ action }` | Create or replace a runtime script/stylesheet and normalize its runtime config. Existing paths are updated, new paths are created |
395+
| `write_code_asset` | `{ path, type, content, runtime?, dependencies? }` | asset summary + `{ action, dependencies }` | Create or replace a runtime script/stylesheet and normalize its runtime config. Existing paths are updated, new paths are created. For module scripts, `dependencies` is a package-name → version/range map added to `site.packageJson.dependencies` |
396396
| `patch_code_asset` | `{ fileId? \| path?, expectedHash, replacements }` | asset summary + `{ replacements }` | Apply exact text replacements only when `expectedHash` matches the latest content. Ambiguous matches require a wider `oldText` or explicit `replaceAll:true` |
397397
| `inspect_code_runtime` | `{ document?: { type, id } }` | `{ pageId, document, scripts, styles }` | Report which runtime scripts/stylesheets apply to the current page/template or supplied page/template document ref |
398398

399399
`insertHtml` / `replaceNodeHtml` intentionally strip `<script>` elements and inline event handlers (`onclick`, `onload`, etc.). When a request needs behavior, the agent should use `write_code_asset({ type: "script", ... })` and then `inspect_code_runtime`, not raw `<script>` tags or event attributes in HTML.
400400

401+
Module scripts that need npm packages should import bare package specifiers and declare those packages in the same `write_code_asset` call:
402+
403+
```ts
404+
write_code_asset({
405+
path: 'src/scripts/motion.js',
406+
type: 'script',
407+
content: `import { Motion } from '@motion.page/sdk';`,
408+
runtime: { format: 'module' },
409+
dependencies: { '@motion.page/sdk': '1.2.4' },
410+
})
411+
```
412+
413+
Agents should not use npm CDN URLs such as `esm.sh`, `unpkg`, or jsDelivr for packages that can live in the site dependency manifest.
414+
401415
**Pages**
402416

403417
| Tool | Input | Success `data` | What it does |

docs/features/html-import.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ Callers splice the fragment into the page tree via `insertImportedNodes(parentId
142142
| `style="…"` attributes | Declarations harvested onto `node.inlineStyles`; the attribute is removed |
143143
| HTML comments and processing instructions | Stripped silently — no count |
144144

145-
The AI agent should not use stripped constructs for behavior. If an edit needs JavaScript, it writes a real runtime script with `write_code_asset({ type: "script", ... })` and verifies targeting with `inspect_code_runtime` instead of embedding `<script>` or `onclick` in an HTML import.
145+
The AI agent should not use stripped constructs for behavior. If an edit needs JavaScript, it writes a real runtime script with `write_code_asset({ type: "script", ... })` and verifies targeting with `inspect_code_runtime` instead of embedding `<script>` or `onclick` in an HTML import. Module scripts import npm packages with bare specifiers and declare them in the same `write_code_asset` call's `dependencies` map.
146146

147147
After insert, `ImportHtmlModal` builds a toast body from the added-selector count plus the non-zero stripped counts, e.g. `"3 CSS selectors, stripped 2 <script>"`. If nothing notable happened, the toast shows only the node count.
148148

docs/features/site-import.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ The static-site pipeline has two parts: a pure analysis function (`buildImportPl
99
## TL;DR
1010

1111
- Entry: global admin-shell modal, opened from Spotlight or workspace actions. Drop files, a folder, a static `.zip`, or a CMS-exported `.zip` bundle. Static files and CMS bundles both use the four-stage modal (Drop → Review → Conflicts → Import, with completion shown inside the Import stage); Conflicts is skipped when there is nothing to resolve.
12-
- `buildImportPlan({ fileMap, currentSite, options })` — pure, synchronous — produces an `ImportPlan` with pages, style rules, kept stylesheet files, media, color tokens, custom fonts, Google font install requests, font tokens, and scripts.
12+
- `buildImportPlan({ fileMap, currentSite, options })` — pure, synchronous — produces an `ImportPlan` with pages, style rules, kept stylesheet files, media, color tokens, custom fonts, Google font install requests, font tokens, scripts, and script npm dependencies inferred from known ESM CDNs.
1313
- **Per-stylesheet import modes:** each top-level linked stylesheet either converts to editable style rules (default) or imports verbatim as a page-scoped `SiteFile` stylesheet (`options.stylesheetModes`, picked in the Review step). There are no generated scope classes — page isolation comes from the kept file's runtime scope.
1414
- `commitImportPlan(plan, adapter)` — uploads assets, then wraps all store writes in a single `adapter.commit` call → one Cmd+Z reverts the whole import.
1515
- Static imports load the current CMS draft into the editor store on demand when launched outside `/admin/site`; if no draft exists, the modal creates an empty site before analysis.
1616
- Conflict resolution: rename with a numeric suffix (default), overwrite, skip, or custom-rename — per page slug, per class name, per design token (colour / font CSS variable), and per divergent cross-stylesheet class definition, with category-level bulk actions. Token renames rewrite `var(--x)` references so imports stay faithful.
17-
- What imports: pages, linked CSS plus unconditional local CSS `@import` graphs, `kind:'class'` and `kind:'ambient'` style rules, stylesheets kept as page-scoped files, `@keyframes`, uploadable media/font files, root CSS color tokens, root CSS font tokens, `@font-face` families, known external font stylesheet imports, safe extra HTML attributes on base modules, body-level classes/attributes/style metadata, bare DOM text nodes in mixed content, and executable HTML scripts as page-scoped runtime scripts.
17+
- What imports: pages, linked CSS plus unconditional local CSS `@import` graphs, `kind:'class'` and `kind:'ambient'` style rules, stylesheets kept as page-scoped files, `@keyframes`, uploadable media/font files, root CSS color tokens, root CSS font tokens, `@font-face` families, known external font stylesheet imports, safe extra HTML attributes on base modules, body-level classes/attributes/style metadata, bare DOM text nodes in mixed content, and executable HTML scripts as page-scoped runtime scripts. Module-script imports from known npm CDNs are rewritten to bare package imports and added to the site dependency manifest.
1818
- CMS bundle import preserves selected exported tables, rows, optional site shell, media, folders, and redirects using the same merge strategies as site transfer (`replace`, `merge-add`, `merge-overwrite`).
1919
- HTML forms import through the shared HTML importer as first-class form primitives (`base.form`, controls, labels, submit buttons), not as custom containers.
2020
- What cannot be modeled: `@layer`, conditional local CSS `@import`, and arbitrary external `@import` — surfaced as warnings when the CSS engine exposes them, never silently dropped.
@@ -35,6 +35,7 @@ src/core/siteImport/
3535
├── colorTokens.ts — extract root custom-property color tokens from :root/html/body rules
3636
├── fontTokens.ts — extract root --font-* custom properties as ImportFontToken[] from :root/html/body rules
3737
├── fontImports.ts — resolve trusted Google CSS2 @import rules into installed-font requests
38+
├── scriptDependencies.ts — rewrite known npm ESM CDN imports in module scripts into bare package imports + dependency requests
3839
├── cssImports.ts — expand unconditional local CSS @import graphs while preserving each source path
3940
├── classCascades.ts — cross-sheet class semantics: detect divergent class definitions as explicit conflicts; apply rename / keep-first / overwrite; enforce one bindable class rule per name
4041
├── mimeTypes.ts — extension → MIME fallback for FileMap entries that carry no MIME type (e.g. ZIP)
@@ -175,6 +176,19 @@ interface ImportPlan {
175176

176177
All URL-shaped values inside `pages[].nodeFragment` props, imported `htmlAttributes` bags, style rule `styles`/`contextStyles`, and supported raw style-rule blocks such as `@keyframes` are normalised to FileMap keys before the plan is returned — `applyAssetRewrites` does exact-string replacement after upload.
177178

179+
`ImportScript` entries carry optional npm dependency requests when module-script CDN imports were converted:
180+
181+
```ts
182+
interface ImportScript {
183+
path: string
184+
content: string
185+
format: 'classic' | 'module'
186+
pageSources: string[]
187+
priority: number
188+
dependencies?: { name: string; version: string }[]
189+
}
190+
```
191+
178192
---
179193

180194
## What each category imports
@@ -188,7 +202,7 @@ All URL-shaped values inside `pages[].nodeFragment` props, imported `htmlAttribu
188202
| **Color tokens** | CSS custom properties on `:root` / `html` / `body` that look like colours | `extractRootColorTokens` pulls them into `ImportColorToken[]`; they become framework palette tokens. The framework parses hex, rgb/rgba, and hsl/hsla into channels (deriving shades/tints/transparent steps); any other authored value (oklch(), color-mix(), …) still emits its base `--<slug>` verbatim so `var(--x)` references never break. A `--<slug>` that collides with an existing colour token surfaces as a `TokenConflict` (rename / skip / overwrite) |
189203
| **Fonts** | Self-hosted `@font-face` families with at least one bundled file, plus trusted Google CSS2 imports | `buildFontFamilies` in `assetPlan.ts` picks the best bundled format (woff2 → woff → ttf → otf); `extractGoogleFontImports` turns Google CSS2 `@import` rules into install requests. Commit uploads custom files via `tx.addFonts`, installs Google families through the CMS Google-font installer, then merges those returned `FontEntry` records via `tx.addInstalledFonts` |
190204
| **Font tokens** | Root `--font-*` variables with font-family stacks | `extractRootFontTokens` pulls them into `ImportFontToken[]`; committed via `tx.addFontTokens` after fonts so matching imported families can be assigned. A `--font-*` that collides with an existing font token surfaces as a `TokenConflict` (rename / skip / overwrite) |
191-
| **Scripts** | Executable inline scripts and JS files linked by imported HTML | Preserved in source order and committed via `tx.addScripts` with page scope from the source HTML. Classic scripts remain plain `<script>` assets and bypass bundling; `type="module"` scripts keep module semantics. Non-executable script data such as `application/json`, import maps, and templates is skipped. |
205+
| **Scripts** | Executable inline scripts and JS files linked by imported HTML | Preserved in source order and committed via `tx.addScripts` with page scope from the source HTML. Classic scripts remain plain `<script>` assets and bypass bundling; `type="module"` scripts keep module semantics. Module imports from known npm CDNs (`esm.sh`, `esm.run`, `unpkg`, jsDelivr npm URLs) are rewritten to bare package specifiers and recorded as runtime dependencies, so `https://esm.sh/@motion.page/sdk@1.2.4` becomes `@motion.page/sdk` plus `@motion.page/sdk: 1.2.4` in `packageJson.dependencies`. Non-executable script data such as `application/json`, import maps, and templates is skipped. |
192206
| **Stylesheets (kept)** | Top-level linked sheets the user opted into `mode: 'file'` | Flattened `@import` graph, Google imports stripped, `url()` normalised; committed via `tx.addStylesheets` as a `SiteFile` (`type: 'style'`) + `site.runtime.styles` entry scoped to the linking pages. Editable afterwards in the Site panel's Styles section and the code editor. |
193207

194208
---

server/ai/tools/site/systemPrompt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Structure as HTML, styling as CSS:
3838
Behavior and runtime code:
3939
- site_insert_html/site_replace_node_html deliberately strip <script> and inline event handlers (onclick/onload/etc). NEVER try to add behavior with <script>, onclick, or custom inline JS in HTML.
4040
- To add behavior such as theme toggles, tabs, menus, filters, or DOM-ready interactions, use site_write_code_asset({ type:"script", path:"src/scripts/...", content, runtime }). The script file is stored in the site file layer and loaded through site.runtime.
41+
- For npm packages in module scripts, import bare package specifiers (e.g. import { Motion } from "@motion.page/sdk") and include dependencies: { "@motion.page/sdk": "1.2.4" } in the SAME site_write_code_asset call. Do not use npm CDN URLs like esm.sh/unpkg/jsDelivr for packages that belong in the site dependency manifest.
4142
- Before changing existing scripts or user stylesheets, call site_list_code_assets/site_read_code_asset. Patch exact spans with site_patch_code_asset using the latest hash; if the text occurs multiple times, use a larger oldText span or replaceAll:true intentionally.
4243
- Use site_inspect_code_runtime after writing code to confirm scripts/styles apply to the current page/template, are enabled, and have the intended priority/placement/timing.
4344

server/ai/tools/site/writeTools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ const writeCodeAssetTool: AiTool = {
245245
execution: 'browser',
246246
requiredCapabilities: SITE_STRUCTURE_CAPS,
247247
description:
248-
'Create or replace a runtime script/style file in site.files and attach normalized site.runtime config. Use `type:"script"` for behavior such as theme toggles, menus, tabs, analytics hooks, and DOM-ready interactions; use `type:"style"` for global user stylesheets that should load as files. `path` is a safe site-relative path such as src/scripts/theme-toggle.js or src/styles/theme.css. `runtime` is optional and merges with existing/default config.',
248+
'Create or replace a runtime script/style file in site.files and attach normalized site.runtime config. Use `type:"script"` for behavior such as theme toggles, menus, tabs, analytics hooks, and DOM-ready interactions; use `type:"style"` for global user stylesheets that should load as files. `path` is a safe site-relative path such as src/scripts/theme-toggle.js or src/styles/theme.css. `runtime` is optional and merges with existing/default config. For module scripts that import npm packages, use bare package imports in `content` and declare them in `dependencies` (package name → semver/range) so they are added to the site dependency manifest; do not use npm CDN URLs for npm packages.',
249249
inputSchema: WriteCodeAssetInputSchema,
250250
}
251251

src/__tests__/agent/executor.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,22 @@ describe('executeAgentTool — code assets', () => {
430430
expect(read.pageInfo.nextPart).toBeNull()
431431
})
432432

433+
it('write_code_asset adds declared runtime dependencies for module scripts', async () => {
434+
freshStore()
435+
436+
const result = await executeAgentTool('site_write_code_asset', {
437+
path: 'src/scripts/motion.js',
438+
type: 'script',
439+
content: `import { Motion } from '@motion.page/sdk';\nwindow.Motion = Motion;`,
440+
runtime: { format: 'module' },
441+
dependencies: { '@motion.page/sdk': '1.2.4' },
442+
})
443+
444+
expectToolOk(result)
445+
expect(useEditorStore.getState().packageJson.dependencies['@motion.page/sdk']).toBe('1.2.4')
446+
expect(useEditorStore.getState().site?.packageJson.dependencies['@motion.page/sdk']).toBe('1.2.4')
447+
})
448+
433449
it('write_code_asset creates a runtime stylesheet and inspect_code_runtime shows page applicability', async () => {
434450
const { rootId } = freshStore()
435451
await executeAgentTool('site_insert_html', { parentId: rootId, html: '<main><h1>Home</h1></main>' })

src/__tests__/editor-store/mutateAllPagesAndSite.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,30 @@ describe('mutateAllPagesAndSite — basic happy path', () => {
149149
const matchingPage = pages.find((p) => p.id === returnedId)
150150
expect(matchingPage).toBeDefined()
151151
})
152+
153+
it('addScripts installs imported runtime dependencies in the same undoable mutation', () => {
154+
useEditorStore.getState().createSite('Test')
155+
156+
useEditorStore.getState().mutateAllPagesAndSite((_site, helpers) => {
157+
helpers.addScripts([{
158+
path: 'motion.js',
159+
content: `import { Motion } from '@motion.page/sdk';`,
160+
format: 'module',
161+
pageSources: ['index.html'],
162+
priority: 100,
163+
dependencies: [{ name: '@motion.page/sdk', version: '1.2.4' }],
164+
}])
165+
return true
166+
})
167+
168+
expect(useEditorStore.getState().packageJson.dependencies['@motion.page/sdk']).toBe('1.2.4')
169+
expect(useEditorStore.getState().site?.packageJson.dependencies['@motion.page/sdk']).toBe('1.2.4')
170+
171+
useEditorStore.getState().undo()
172+
173+
expect(useEditorStore.getState().packageJson.dependencies['@motion.page/sdk']).toBeUndefined()
174+
expect(useEditorStore.getState().site?.packageJson.dependencies['@motion.page/sdk']).toBeUndefined()
175+
})
152176
})
153177

154178
// ---------------------------------------------------------------------------

src/__tests__/siteImport/applyImport.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,30 @@ describe('buildImportPlan — structure', () => {
267267
expect(plan.scripts.map((s) => s.path)).not.toContain('scripts/unused.js')
268268
})
269269

270+
it('converts npm CDN module imports into package dependencies', () => {
271+
const encoder = new TextEncoder()
272+
const p = buildImportPlan({
273+
fileMap: {
274+
files: {
275+
'index.html': {
276+
bytes: encoder.encode(`<!doctype html><html><body><script type="module" src="./motion.js"></script></body></html>`),
277+
mimeType: 'text/html',
278+
},
279+
'motion.js': {
280+
bytes: encoder.encode(`import { Motion } from 'https://esm.sh/@motion.page/sdk@1.2.4';\nwindow.Motion = Motion;`),
281+
mimeType: 'application/javascript',
282+
},
283+
},
284+
},
285+
currentSite,
286+
})
287+
288+
expect(p.scripts).toHaveLength(1)
289+
expect(p.scripts[0]!.content).toContain(`from '@motion.page/sdk'`)
290+
expect(p.scripts[0]!.content).not.toContain('https://esm.sh')
291+
expect(p.scripts[0]!.dependencies).toEqual([{ name: '@motion.page/sdk', version: '1.2.4' }])
292+
})
293+
270294
it('imports executable inline scripts before the linked scripts that follow them', () => {
271295
const html = `<!doctype html><html><body>
272296
<script>var duration='500',easing='swing';</script>

0 commit comments

Comments
 (0)