Skip to content
Open
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: 2 additions & 2 deletions docs/features/html-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Callers splice the fragment into the page tree via `insertImportedNodes(parentId
| `select` | `base.select` | `fieldId`, `name`, `id`, `required`, `disabled`, `multiple` | Yes |
| `optgroup` | `base.option-group` | `label`, `disabled` | Yes |
| `option` | `base.option` | `value`, `label`, `selected`, `disabled` | No |
| `button` | `base.button`, `base.submit` when submit-type / inside a form without a type, or `base.container` tagged `button` when a non-submit button wraps element children | `label`, `disabled` | No for text-only and for submit buttons; yes for compound non-submit buttons |
| `button` | `base.button`, `base.submit` when submit-type / inside a form without a type, or `base.container` tagged `button` when a non-submit button wraps element children | `label`, `disabled` | No for text-only; yes when the button wraps element children |
| `ul`, `ol` | `base.container` | `tag` = tag name | Yes |
| `div`, `section`, `article`, `main`, `header`, `footer`, `nav`, `aside` | `base.container` | `tag` = tag name | Yes |
| `area`, `base`, `br`, `col`, `embed`, `hr`, `link`, `meta`, `param`, `source`, `track`, `wbr` (void elements) | `base.container` | `tag: 'custom'`, `customTag` = tag name | **No** |
Expand All @@ -122,7 +122,7 @@ Callers splice the fragment into the page tree via `insertImportedNodes(parentId
- **Direct text inside a recursing container is preserved.** The walker iterates `childNodes` (not just `children`): element children route through the rules, and each significant text node becomes a synthesized `base.text` child with `tag: 'none'` in document order. That no-wrapper text mode publishes back to bare text, so `<div class="num">98%</div>` and `<li>Buy milk</li>` import as containers holding their original text without adding selector-visible wrapper elements. Whitespace-only text (indentation between tags) is skipped; internal whitespace runs collapse to single spaces, and boundary spaces are kept when the text run sits between element siblings.
- **`<body>` metadata is preserved separately.** Classes, safe HTML attributes (`id`, ARIA, `data-*`, etc.), and harvested inline styles on `<body>` are returned as `fragment.body` rather than inserted into `rootIds`. Full-site import applies them to `base.body`; paste-style HTML import can ignore them without changing the fragment structure.
- `base.link` uses the prop `text` (not `label`). `base.button` uses `label` (not `text`). These match the module source.
- **Button-like elements keep what they wrap.** `base.button` is `canHaveChildren: false`, so an `a.btn` or a `<button>` wrapping an icon, inline `<svg>` or `<img>` would import as a label-only leaf and lose the rest without saying so. Those recurse instead: a compound `.btn` anchor maps to `base.link` (keeping `href` and `target`), and a compound non-submit `<button>` maps to a `base.container` tagged `button`. Class names ride along as classIds, so `.btn` styling survives the module swap. Submit buttons stay `base.submit` even when compound, because `core/forms` identifies a form's submit control by that module id — so a compound submit button still keeps only its label.
- **Button-like elements keep what they wrap.** `base.button` is `canHaveChildren: false`, so an `a.btn` or a `<button>` wrapping an icon, inline `<svg>` or `<img>` would import as a label-only leaf and lose the rest without saying so. Those recurse instead: a compound `.btn` anchor maps to `base.link` (keeping `href` and `target`), and a compound non-submit `<button>` maps to a `base.container` tagged `button`. Class names ride along as classIds, so `.btn` styling survives the module swap. Submit buttons stay `base.submit` even when compound, because `core/forms` identifies a form's submit control by that module id; `base.submit` accepts children, so an icon-only submit (an inline `<svg>`, an icon-font `<i>`, an `<img>`) keeps its icon as child nodes and the `label` prop is only the childless fallback.
- `base.image` captures `src` and the authored `loading` / `decoding` / `fetchpriority` hints (unknown values keep the module defaults). `alt` is not a per-instance prop — it comes from the media library asset — so the walker reports it per node in `WalkResult.imageAlts` (an empty string is a deliberate decorative alt) and Site Import creates the media record with it.
- **Form elements import as form primitives.** Third-party `<form>` elements default to `base.form` in `custom` mode, so they do not become CMS submission endpoints until an author binds them to a data table. Published CMS-native forms can round-trip their `data-instatic-*` form metadata. Plain labels become `base.label`; labels that wrap controls become a `base.container` with `customTag:'label'` so nested inputs are not dropped.
- **Void elements** (`<br>`, `<hr>`, etc.) have their own rule that sits before the catch-all. They map to `base.container` with `tag:'custom'` + the real tag name, but with `recurse:false` so the produced node has no children. `<input>` is not part of this fallback anymore; it imports through the form-control rule. The canvas renderer (`ContainerEditor`) also guards against passing children (including the empty-container placeholder) to void element tags, because React throws if you do so.
Expand Down
2 changes: 1 addition & 1 deletion docs/features/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ src/modules/base/
├── video/ — base.video (youtube.ts — ID parse + embed URL leaf)
├── list/ — base.list (items.ts — textarea item-splitting leaf)
├── loop/ — base.loop
├── forms/ — base.form and form-control primitives
├── forms/ — base.form and form-control primitives (content.ts: submit children/label rule)
├── visualComponentRef/ — base.visual-component-ref
├── slotOutlet/ — base.slot-outlet (VC author side)
├── slotInstance/ — base.slot-instance (VC consumer side)
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/module-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ Name the leaf after what it owns, not generically:
|------------------|-----------------|-------------------------------------------------------|
| `base.button` | `anchor.ts` | `resolveButtonAnchor()` — element decision (`<a>` vs `<button>`) |
| `base.link` | `content.ts` | `linkUsesChildren()` — children/text fallback rule |
| `base.submit` | `forms/content.ts` | `submitUsesChildren()` — children/label fallback rule |
| `base.list` | `items.ts` | `parseItems()` — textarea → trimmed non-empty array |
| `base.video` | `youtube.ts` | `parseYoutubeId()`, `youtubeEmbedUrl()` — embed URL |
| `base.text` | `tags.ts` | `normalizeTag()`, `TextTag` — semantic tag coercion |
Expand Down Expand Up @@ -424,6 +425,7 @@ The publisher emits a `<script type="importmap">` entry. `getMissingModuleDepend
- `src/modules/base/shared/anchorTarget.ts` — `AnchorTargetSchema`, `anchorRel()` (cross-module shared vocabulary)
- `src/modules/base/button/anchor.ts` — `resolveButtonAnchor()` (per-module shared leaf)
- `src/modules/base/link/content.ts` — `linkUsesChildren()` (per-module shared leaf)
- `src/modules/base/forms/content.ts` — `submitUsesChildren()` (per-module shared leaf)
- `src/modules/base/list/items.ts` — `parseItems()` (per-module shared leaf)
- `src/modules/base/video/youtube.ts` — `parseYoutubeId()`, `youtubeEmbedUrl()` (per-module shared leaf)
- `src/core/htmlAttributes/tags.ts` — `resolveHtmlTag`, `BUILTIN_HTML_TAGS`, `VOID_HTML_ELEMENTS`
Expand Down
6 changes: 3 additions & 3 deletions src/__tests__/htmlImport/structurePreservation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,12 @@ describe('compound buttons keep what they wrap', () => {
expect(root.children).toHaveLength(0)
})

it('a submit button stays base.submit even when compound', () => {
it('a compound submit button stays base.submit and keeps its children', () => {
// core/forms finds a form's submit control by module id, so a compound
// submit must not be re-tagged as a container. It keeps only its label.
// submit must not be re-tagged as a container; its content recurses.
const r = importHtml('<form><button><svg viewBox="0 0 1 1"></svg><span>Send</span></button></form>')
const submit = Object.values(r.nodes).find((n) => n.moduleId === 'base.submit')
expect(submit).toBeDefined()
expect(submit!.children).toHaveLength(0)
expect(submit!.children).toHaveLength(2)
})
})
99 changes: 99 additions & 0 deletions src/__tests__/htmlImport/submitButtonChildren.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Icon-only submit buttons keep their icon through import and publish.
*
* base.submit was a leaf whose only content was the `label` string, with a
* 'Submit' fallback when the button had no text. Real markup often puts an
* icon there instead of text: a newsletter form's arrow as an inline `<svg>`,
* a search box's magnifier as an icon-font `<i>`, an `<img>` of a button
* graphic. Every one of them imported and published as the literal word
* "Submit". The importer now recurses into submit buttons with element
* children, and base.submit renders children when it has them, `label` when
* it doesn't: the base.link contract.
*/
import { describe, it, expect } from 'bun:test'
import '@modules/base'
import { importHtml } from '@core/htmlImport'
import { SubmitModule } from '@modules/base/forms'

/** Import `html` and return the base.submit node plus its resolved children. */
function submitNodeOf(html: string) {
const result = importHtml(`<html><body>${html}</body></html>`)
const node = Object.values(result.nodes).find((n) => n.moduleId === 'base.submit')
if (!node) throw new Error('no base.submit node was produced')
return { node, kids: node.children.map((id) => result.nodes[id]!) }
}

describe('submit buttons with element children recurse', () => {
it('keeps an inline <svg> arrow as a base.svg child', () => {
const { node, kids } = submitNodeOf(
'<form><button type="submit" style="width:45px">' +
'<svg xmlns="http://www.w3.org/2000/svg" width="13" height="12" viewBox="0 0 13 12">' +
'<path d="M7.21 11.7L12.21 6.7"/></svg></button></form>',
)
expect(kids.length).toBe(1)
expect(kids[0]!.moduleId).toBe('base.svg')
expect(String(kids[0]!.props.svg)).toContain('<svg')
// Label keeps the childless fallback; render ignores it while children exist.
expect(node.props.label).toBe('Submit')
})

it('keeps an icon-font <i> child with its classes', () => {
const { kids } = submitNodeOf(
'<form><button type="submit"><i class="fa fa-search"></i></button></form>',
)
expect(kids.length).toBe(1)
expect(kids[0]!.props.customTag).toBe('i')
expect(kids[0]!.classIds).toContain('fa')
expect(kids[0]!.classIds).toContain('fa-search')
})

it('keeps an <img> child', () => {
const { kids } = submitNodeOf(
'<form><button type="submit"><img src="https://example.com/btn-img.svg" alt="Submit"></button></form>',
)
expect(kids.length).toBe(1)
expect(kids[0]!.moduleId).toBe('base.image')
expect(kids[0]!.props.src).toBe('https://example.com/btn-img.svg')
})

it('keeps the text beside an icon as a base.text child and in the label', () => {
const { node, kids } = submitNodeOf(
'<form><button type="submit">Send <i class="icon-arrow"></i></button></form>',
)
expect(kids.length).toBe(2)
expect(kids[0]!.moduleId).toBe('base.text')
expect(String(kids[0]!.props.text)).toContain('Send')
expect(kids[1]!.props.customTag).toBe('i')
expect(node.props.label).toBe('Send')
})

it('a text-only submit stays a leaf with its label', () => {
const { node, kids } = submitNodeOf('<form><button type="submit">Send</button></form>')
expect(kids.length).toBe(0)
expect(node.props.label).toBe('Send')
})

it('a typeless button inside a form is treated as a submit and recurses', () => {
const { kids } = submitNodeOf(
'<form><button><i class="icon-magnifier"></i></button></form>',
)
expect(kids.length).toBe(1)
expect(kids[0]!.props.customTag).toBe('i')
})
})

describe('base.submit render: children win, label is the fallback', () => {
const props = { ...SubmitModule.defaults, label: 'Submit', disabled: false, formId: '' }

it('renders children when present and omits the label', () => {
const html = SubmitModule.render!(props, ['<svg viewBox="0 0 13 12"></svg>']).html
expect(html).toContain('<svg viewBox="0 0 13 12"></svg>')
expect(html).not.toContain('>Submit<')
expect(html).toContain('type="submit"')
})

it('renders the label when there are no children', () => {
const html = SubmitModule.render!(props, []).html
expect(html).toContain('>Submit</button>')
})
})
9 changes: 5 additions & 4 deletions src/core/htmlImport/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,9 +461,10 @@ export const HTML_TO_MODULE_RULES: ImportRule[] = [
// base.button is `canHaveChildren: false` and would keep only the text.
// Submit buttons stay on base.submit even when compound: `core/forms`
// identifies a form's submit control by that module id, so re-tagging one as
// a container would leave the form without a submit. A compound submit button
// therefore still keeps only its label — lifting that needs base.submit to
// accept children, which is a module change rather than an importer one.
// a container would leave the form without a submit. base.submit accepts
// children, so a compound submit recurses too and its content (an icon-only
// `<svg>`, an icon-font `<i>`, an `<img>`) survives as child nodes; `label`
// stays as the childless fallback, the base.link contract.
{
match: 'button',
map: (el) => {
Expand Down Expand Up @@ -494,7 +495,7 @@ export const HTML_TO_MODULE_RULES: ImportRule[] = [
},
}
},
recurse: (el) => !isSubmitButton(el) && hasElementChild(el),
recurse: hasElementChild,
},

// ul / ol are BUILTIN_HTML_TAGS for base.container → container + RECURSE.
Expand Down
6 changes: 4 additions & 2 deletions src/modules/base/forms/FormControls.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ModuleComponentProps } from '@core/module-engine'
import { normalizeIdentifierValue } from '@core/utils/identifier'
import { submitUsesChildren } from './content'

type FormProps = Record<string, unknown> & {
formId: string
Expand Down Expand Up @@ -206,8 +207,9 @@ export function RadioEditor({ mcClassName, nodeWrapperProps, props }: ModuleComp
)
}

export function SubmitEditor({ mcClassName, nodeWrapperProps, props }: ModuleComponentProps<SubmitProps>) {
export function SubmitEditor({ mcClassName, nodeWrapperProps, props, children }: ModuleComponentProps<SubmitProps>) {
const formId = normalizeIdentifierValue(props.formId)
const childCount = Array.isArray(children) ? children.length : children != null ? 1 : 0
return (
<button
{...nodeWrapperProps}
Expand All @@ -216,7 +218,7 @@ export function SubmitEditor({ mcClassName, nodeWrapperProps, props }: ModuleCom
disabled={props.disabled}
form={formId || undefined}
>
{props.label}
{submitUsesChildren(childCount) ? children : props.label}
</button>
)
}
Expand Down
24 changes: 24 additions & 0 deletions src/modules/base/forms/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Content-source decision for `base.submit`, shared by the publisher
* `render()` path (`index.ts`) and the canvas preview (`FormControls.tsx`) so
* the two cannot drift. Mirrors `base.link`'s `linkUsesChildren` — see
* `@modules/base/link/content` for why this is an explicit count check rather
* than a `children ?? label` short-circuit (an empty children array is not
* nullish).
*
* Rule: a submit button renders its children whenever it HAS children;
* otherwise it falls back to the `label` prop. Imported icon-only submits
* (an inline `<svg>` arrow, an icon-font `<i>`, an `<img>`) carry their icon
* as real child nodes and must not be flattened to the label text.
*
* Non-component `.ts` leaf so the editor component can import it without
* breaking React Fast Refresh (Constraint #309).
*/

/**
* Whether the submit button should render its own children (`true`) or fall
* back to the `label` prop (`false`), given how many rendered children it has.
*/
export function submitUsesChildren(childCount: number): boolean {
return childCount > 0
}
19 changes: 14 additions & 5 deletions src/modules/base/forms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import type { ModuleDefinition } from '@core/module-engine'
import { registry } from '@core/module-engine'
import { Type, Value, type Static } from '@core/utils/typeboxHelpers'
import { submitUsesChildren } from './content'
import { normalizeIdentifierValue } from '@core/utils/identifier'
import { safeUrl } from '@modules/base/utils/escape'
import { FORM_RUNTIME_JS } from './formRuntimeJs'
Expand Down Expand Up @@ -422,19 +423,27 @@ export const SubmitModule: ModuleDefinition<SubmitProps> = {
version: '1.0.0',
icon: SendSolidIcon,
trusted: true,
canHaveChildren: false,
// Children carry imported icon-only submit content (an inline `<svg>`,
// an icon-font `<i>`, an `<img>`) as real nodes; `label` is the childless
// fallback. Same children-vs-text contract as base.link.
canHaveChildren: true,
schema: {
label: { type: 'text', label: 'Label' },
label: { type: 'text', label: 'Label', placeholder: 'Displayed when no children' },
disabled: { type: 'toggle', label: 'Disabled' },
formId: { type: 'text', label: 'Form ID override', normalize: 'identifier' },
},
propsSchema: SubmitPropsSchema,
defaults: Value.Create(SubmitPropsSchema),
component: SubmitEditor,
htmlTag: 'button',
render: (props) => ({
html: `<button type="submit"${attrs([['form', normalizeIdentifierValue(props.formId)]])}${booleanAttrs(props, ['disabled'])}>${props.label}</button>`,
}),
render: (props, renderedChildren) => {
const content = submitUsesChildren(renderedChildren.length)
? renderedChildren.join('')
: props.label
return {
html: `<button type="submit"${attrs([['form', normalizeIdentifierValue(props.formId)]])}${booleanAttrs(props, ['disabled'])}>${content}</button>`,
}
},
}

export const FormMessageModule: ModuleDefinition<FormMessageProps> = {
Expand Down
Loading