Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/features/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ src/admin/pages/site/agent/
├── pageContext.ts — editor adapter: reads active page + store scalars, calls `buildSiteAgentSnapshot`
├── executor.ts — browser-side dispatcher: validates + runs write tools; auto-navigates canvas to node's owning document before each write
├── cssTools.ts — site_apply_css parser + exact-selector merge/replace/delete runners
├── documentTools.ts — list/read/open document helpers for pages, templates, and visual components
├── documentTools.ts — list/read/open document helpers for pages, templates, and visual components, plus active-document node lookup
├── htmlTools.ts — site_insert_html / site_replace_node_html runners and the stripped / head-only import notices (split from executor.ts)
├── tokenRunners.ts — site_set_color_tokens / site_set_font_tokens / site_set_type_scale / site_set_spacing_scale runners (split from executor.ts)
├── renderEvidence.ts — captureAgentRenderSnapshot (site_render_snapshot tool)
├── storeRef.ts — setAgentStoreApi / getAgentStoreApi (avoids store ↔ executor cycle)
Expand Down Expand Up @@ -580,6 +581,7 @@ Drivers that support explicit prompt-cache controls (Anthropic) apply `cache_con
- **Design system first.** Establish or reuse tokens before/while building (`site_set_color_tokens`, `site_set_type_scale`, `site_set_spacing_scale`, `site_set_font_tokens`), then reference them in CSS (`var(--<slug>)`, `var(--text-l)`, `var(--space-m)`, `var(--<font-var>)`) instead of raw hex/px/font-family. The dynamic suffix's `Tokens —` line shows what already exists; `(none …)` means no design system yet.
- Structure as HTML (`site_insert_html` / `site_replace_node_html`); style with CSS in the same payload — a `<style>` block and/or `class=` attributes referencing the design tokens. The importer classifies selectors, so the agent never hand-builds classes at insert time.
- `<style>` blocks inside imported HTML are parsed: class-bearing selectors become Selectors-panel classes while preserving complex selector text, class-free selectors (`a:hover`) become ambient rules, and supported `@keyframes` publish as raw keyframes CSS. `style=` attributes land on the node's inline styles. These are applied — not stripped.
- What the importer does drop comes back as `warnings` on the tool result: `<script>` and `on*` handlers (write a code asset instead), and `<link>` / `<meta>` / `<title>` head elements, which the site generates from its settings. A payload made only of those is rejected with an error naming them, so a bare `<link rel="icon">` is never a silent no-op.
- CSS-only edits use an explicit `site_apply_css` operation: merge for additive patches, remove-properties for stale declarations, replace only with the selector's complete desired CSS, and delete for whole exact rules. Read the document first before destructive operations; grouped and ungrouped selectors are different identities.
- One `site_insert_html` call per logical section (nav, hero, pricing, footer = 4–6 calls); smaller chunks recover better if one fails.
- Per-breakpoint variation: `@media` queries — in the `<style>` block of an insert or inside `site_apply_css` — with min/max-width queries that line up with the breakpoint widths in the dynamic suffix. Never invent ids like `"mobile"` or `"desktop"`.
Expand Down
11 changes: 8 additions & 3 deletions docs/features/html-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The module has two consumers: the paste-HTML UI and the AI agent's `insertHtml`

## TL;DR

- Single entry point: `importHtml(source)` → `{ nodes, rootIds, body?, stripped, styleCss }`.
- Single entry point: `importHtml(source)` → `{ nodes, rootIds, body?, stripped, headOnly, styleCss }`.
- Pipeline: `parseHtml` → `harvestInlineStyles` + `collectStyleCss` → `stripUnsafe` → `walkAndMap`.
- Mapping is rule-driven (`HTML_TO_MODULE_RULES`). The catch-all `*` rule guarantees every element produces a node — nothing falls through.
- Every produced node is a real `PageNode`: selectable, draggable, deletable, and re-styleable in the canvas.
Expand Down Expand Up @@ -58,7 +58,7 @@ importHtml(source: string)
4. walkAndMap(doc, inlineStyles)— maps doc.body element children to PageNodes,
attaching each harvested inline bag to its
node's `inlineStyles`. Returns { nodes, rootIds }
→ { nodes, rootIds, body?, stripped, styleCss } (ImportResult)
→ { nodes, rootIds, body?, stripped, headOnly, styleCss } (ImportResult)
```

### Return type
Expand All @@ -78,6 +78,10 @@ interface ImportResult {
}
/** Counts of constructs removed by stripUnsafe (scripts, inline handlers). */
stripped: StripReport
/** Tag names the parser placed in <head> (link, meta, title, base, …), which the
* walker never sees. Deduplicated, document order. A bare `<link rel="icon">` at
* the top of a payload lands here in every browser. */
headOnly: string[]
/** Raw concatenated CSS from <style> blocks. Empty when the source had none. */
styleCss: string
}
Expand Down Expand Up @@ -142,10 +146,11 @@ Callers splice the fragment into the page tree via `insertImportedNodes(parentId
| `<style>` elements | CSS harvested into `result.styleCss` (then parsed into registry rules); the element is removed |
| `style="…"` attributes | Declarations harvested onto `node.inlineStyles`; the attribute is removed |
| HTML comments and processing instructions | Stripped silently — no count |
| `<link>`, `<meta>`, `<title>`, `<base>` and anything else the parser puts in `<head>` | Never walked (the walker maps `doc.body`); reported by tag name as `headOnly` |

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.

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.
Both consumers tell the author what was dropped. The agent tools (`site_insert_html`, `site_replace_node_html`) return the stripped counts and the head-only tags as sentences in `warnings` next to the importer's own reference warnings, and fold the same sentences into the error when nothing was importable, so a bare `<link rel="icon">` is rejected as "Ignored `<link>`: these belong in the document `<head>`…" rather than a bare "no importable elements". `ImportHtmlModal` builds its toast body from the added-selector count plus the same notices, e.g. `"3 CSS selectors, stripped 2 <script>, ignored <link> from <head>"`. If nothing notable happened, the toast shows only the node count.

**Inline `style="…"` → `node.inlineStyles`.** Before `stripUnsafe` removes a `style` attribute, `harvestInlineStyles` (`inlineStyle.ts`) reads the element's parsed CSSOM declaration and copies **every** declaration into a camelCase bag, dropping only property names rejected by `isEmittableProperty` (the publisher's security denylist — the same gate `cssToStyleRules` uses). A `url(…)` background is canonicalised to `url('payload')` form so the Super Import asset rewriter and the editor's `BackgroundImageControl` recognise it. The bag is attached to the produced node as `node.inlineStyles` — the editor's first-class per-node `style=""` layer — which the publisher emits verbatim and the user edits via the Properties panel's inline-style mode. In Super Import any `url(…)` is uploaded to the media library and rewritten to its media URL.

Expand Down
4 changes: 2 additions & 2 deletions server/ai/tools/site/writeTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const insertHtmlTool: AiTool = {
execution: 'browser',
requiredCapabilities: SITE_STRUCTURE_CAPS,
description:
'Insert semantic HTML as a subtree of editable nodes under an existing parent. Write structure as HTML (<section>, <h1>, <a>, <button>, <img>, <ul>, ...) and style it with CSS in the same call: put a <style> block in the HTML and/or class= attributes. Custom importer markers: <instatic-loop data-source-id="…" ...> creates a real Loop node (call site_list_loop_sources first for source/table ids and {currentEntry.*} tokens; never place the loop inside a <table>, <tbody> or <tr> — HTML parsing moves it out of the table and leaves its rows behind, publishing one blank row. Wrap the whole <table> in the loop, or use a list); <instatic-outlet data-tag="div"> creates a neutral template content outlet. On PAGE routes the outlet element is not rendered at all — the page content is spliced in at its position — so wrap the outlet in <main> yourself if pages should have a main landmark. data-tag only takes effect on ENTRY routes, where the outlet stays and wraps the entry body (use data-custom-tag for a safe custom element). The importer parses every rule — a bare `.foo {}` selector becomes a reusable Selectors-panel class bound to class="foo"; any other selector (`.hero a`, `a:hover`, `nav > li`) becomes an ambient rule. Inline style= attributes land on the node\'s inline styles. To author or edit CSS on its own — pseudo/hover/descendant selectors, or restyling existing rules — use the dedicated site_apply_css tool instead (site_insert_html is for inserting structure). Returns `nodeIds` (the inserted roots) and `created` — every inserted node as { id, moduleId, classes } — so you can target a nested node (e.g. the wrapper you just added) without re-reading the whole tree.',
'Insert semantic HTML as a subtree of editable nodes under an existing parent. Write structure as HTML (<section>, <h1>, <a>, <button>, <img>, <ul>, ...) and style it with CSS in the same call: put a <style> block in the HTML and/or class= attributes. Custom importer markers: <instatic-loop data-source-id="…" ...> creates a real Loop node (call site_list_loop_sources first for source/table ids and {currentEntry.*} tokens; never place the loop inside a <table>, <tbody> or <tr> — HTML parsing moves it out of the table and leaves its rows behind, publishing one blank row. Wrap the whole <table> in the loop, or use a list); <instatic-outlet data-tag="div"> creates a neutral template content outlet. On PAGE routes the outlet element is not rendered at all — the page content is spliced in at its position — so wrap the outlet in <main> yourself if pages should have a main landmark. data-tag only takes effect on ENTRY routes, where the outlet stays and wraps the entry body (use data-custom-tag for a safe custom element). The importer parses every rule — a bare `.foo {}` selector becomes a reusable Selectors-panel class bound to class="foo"; any other selector (`.hero a`, `a:hover`, `nav > li`) becomes an ambient rule. Inline style= attributes land on the node\'s inline styles. To author or edit CSS on its own — pseudo/hover/descendant selectors, or restyling existing rules — use the dedicated site_apply_css tool instead (site_insert_html is for inserting structure). Returns `nodeIds` (the inserted roots) and `created` — every inserted node as { id, moduleId, classes } — so you can target a nested node (e.g. the wrapper you just added) without re-reading the whole tree. `warnings` lists anything the importer dropped or ignored: <script> and on* handlers (write a code asset instead), and <link>/<meta>/<title> head elements, which the site generates from its settings (favicon, title) and never authors as nodes. A payload made only of those is rejected with an error naming them.',
inputSchema: InsertHtmlInputSchema,
}

Expand Down Expand Up @@ -124,7 +124,7 @@ const replaceNodeHtmlTool: AiTool = {
execution: 'browser',
requiredCapabilities: SITE_STRUCTURE_CAPS,
description:
"Replace a node subtree's children with new HTML. The target node is preserved as the parent; its existing children are rebuilt from the HTML. Style with CSS exactly as in site_insert_html: a <style> block and/or class= attributes; bare `.foo` selectors become reusable classes, other selectors become ambient rules. Custom importer markers work here too: <instatic-loop data-source-id=\"…\" ...> creates a real Loop node and <instatic-outlet data-tag=\"div\"> creates a neutral template content outlet (omit data-tag only when the outlet should render as main). To author or edit CSS on its own (without rebuilding children), use the dedicated site_apply_css tool instead.",
"Replace a node subtree's children with new HTML. The target node is preserved as the parent; its existing children are rebuilt from the HTML. Style with CSS exactly as in site_insert_html: a <style> block and/or class= attributes; bare `.foo` selectors become reusable classes, other selectors become ambient rules. Custom importer markers work here too: <instatic-loop data-source-id=\"…\" ...> creates a real Loop node and <instatic-outlet data-tag=\"div\"> creates a neutral template content outlet (omit data-tag only when the outlet should render as main). To author or edit CSS on its own (without rebuilding children), use the dedicated site_apply_css tool instead. `warnings` lists anything the importer dropped or ignored (<script> and on* handlers, <link>/<meta>/<title> head elements), exactly as site_insert_html does.",
inputSchema: ReplaceNodeHtmlInputSchema,
}

Expand Down
72 changes: 72 additions & 0 deletions src/__tests__/agent/executorImportNotices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* #490 — what the HTML tools say about markup they could not keep.
*
* Two silent outcomes were reported: `site_replace_node_html` dropped a
* `<script>` with no trace in the result, and a head-only payload (a bare
* `<link rel="icon">`) came back as "no importable elements" without naming
* the element. Both tools now report stripped and ignored constructs, as
* `warnings` on success and inside the error when nothing was importable.
*/
import { describe, expect, it } from 'bun:test'
import { useEditorStore } from '@site/store/store'
import { executeAgentTool } from '@site/agent'
import '@modules/base'

function freshRoot(): string {
useEditorStore.setState({ site: null })
const site = useEditorStore.getState().createSite('Test')
return site.pages[0].rootNodeId
}

// The browser parser moves a bare top-level <link> into <head>; the test
// polyfill only does so for an explicit document, so spell the head out.
const HEAD_ONLY = '<html><head><link rel="icon" type="image/svg+xml" href="/favicon.svg"></head><body></body></html>'

describe('HTML tool import notices', () => {
it('insertHtml names the head-only element instead of a bare "no importable elements"', async () => {
const result = await executeAgentTool('site_insert_html', { parentId: freshRoot(), html: HEAD_ONLY })
expect(result.ok).toBe(false)
expect(result.error).toContain('no importable elements')
expect(result.error).toContain('<link>')
expect(result.error).toContain('<head>')
})

it('replaceNodeHtml reports a stripped <script> as a warning while still inserting the rest', async () => {
const rootId = freshRoot()
const wrapper = await executeAgentTool('site_insert_html', { parentId: rootId, html: '<div></div>' })
const wrapperId = (wrapper.data as { nodeIds: string[] }).nodeIds[0]

const result = await executeAgentTool('site_replace_node_html', {
nodeId: wrapperId,
html: '<section><h1>Hi</h1><script>console.log(1)</script></section>',
})
expect(result.ok).toBe(true)
const data = result.data as { nodeIds: string[]; warnings?: string[] }
expect(data.nodeIds).toHaveLength(1)
expect(data.warnings).toHaveLength(1)
expect(data.warnings?.[0]).toContain('1 <script> element')
expect(data.warnings?.[0]).toContain('site_write_code_asset')
})

it('insertHtml reports stripped inline handlers and ignored head elements alongside inserted nodes', async () => {
const result = await executeAgentTool('site_insert_html', {
parentId: freshRoot(),
html: '<html><head><meta name="description" content="x"><title>T</title></head>'
+ '<body><button onclick="go()">Go</button></body></html>',
})
expect(result.ok).toBe(true)
const data = result.data as { warnings?: string[] }
expect(data.warnings).toHaveLength(2)
expect(data.warnings?.[0]).toContain('1 inline event handler attribute')
expect(data.warnings?.[1]).toContain('<meta>, <title>')
})

it('stays silent when nothing was dropped', async () => {
const result = await executeAgentTool('site_insert_html', {
parentId: freshRoot(),
html: '<section><p>Plain</p></section>',
})
expect(result.ok).toBe(true)
expect((result.data as { warnings?: string[] }).warnings).toBeUndefined()
})
})
34 changes: 34 additions & 0 deletions src/__tests__/htmlImport/headOnlyElements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* #490 — a payload the parser places entirely in `<head>` (a bare
* `<link rel="icon">` is the reported case) produced an empty fragment and
* nothing that said why. `importHtml` now reports those tags as `headOnly`
* so callers can name them.
*/
import { describe, expect, it } from 'bun:test'
import '@modules/base'
import { importHtml } from '@core/htmlImport'

describe('importHtml head-only elements', () => {
it('reports head elements by tag, deduplicated, in document order', () => {
const result = importHtml(
'<!doctype html><html><head><title>T</title><link rel="icon" href="/favicon.svg">'
+ '<meta charset="utf-8"><link rel="stylesheet" href="x.css"><base href="/"></head><body></body></html>',
)
expect(result.rootIds).toEqual([])
expect(result.headOnly).toEqual(['title', 'link', 'meta', 'base'])
})

it('does not list scripts and styles twice: they have their own report', () => {
const result = importHtml(
'<html><head><script>1</script><style>.a{color:red}</style><meta name="x" content="y"></head><body><p>hi</p></body></html>',
)
expect(result.stripped.scripts).toBe(1)
expect(result.styleCss).toContain('.a')
expect(result.headOnly).toEqual(['meta'])
expect(result.rootIds).toHaveLength(1)
})

it('is empty for ordinary body markup', () => {
expect(importHtml('<section><h1>Hi</h1></section>').headOnly).toEqual([])
})
})
5 changes: 4 additions & 1 deletion src/admin/modals/ImportHtml/ImportHtmlModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,11 +267,14 @@ export function ImportHtmlModal() {
if (rules.length) {
detailParts.push(`${rules.length} CSS selector${rules.length > 1 ? 's' : ''}`)
}
const { stripped } = result
const { stripped, headOnly } = result
if (stripped.scripts) detailParts.push(`stripped ${stripped.scripts} <script>`)
if (stripped.inlineHandlers) {
detailParts.push(`stripped ${stripped.inlineHandlers} inline handler${stripped.inlineHandlers > 1 ? 's' : ''}`)
}
if (headOnly.length) {
detailParts.push(`ignored ${headOnly.map((tag) => `<${tag}>`).join(', ')} from <head>`)
}
const toastBody = detailParts.length > 0 ? detailParts.join(', ') : undefined

pushToast({ kind: 'success', title: toastTitle, body: toastBody })
Expand Down
Loading
Loading