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
2 changes: 1 addition & 1 deletion docs/features/html-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ The importer is "approximate by construction". Several inputs do not survive the
| Input | What happens | Why |
|---|---|---|
| `alt=""` on `<img>` | Reported in `imageAlts`, not stored on the node | `base.image` has no `alt` prop — alt text lives on the media library asset, which Site Import creates with the authored value |
| Safe HTML attributes not modeled by the matched module (`id`, ARIA attrs, `role`, custom attrs, `data-*`, etc.) | Preserved in `props.htmlAttributes` on base container/text/link/button/image nodes and editable in the Properties panel Attributes view. `class` names become registry classes, inline `style` declarations become `node.inlineStyles`, event handlers are stripped, reserved editor/runtime `data-*` names are not imported, and attributes already owned by the module (for example `href` on links and `src` on images) stay in their first-class module props. | The module schema owns modeled props; `htmlAttributes` is the safe escape hatch for extra authored attributes |
| Safe HTML attributes not modeled by the matched module (`id`, ARIA attrs, `role`, custom attrs, `data-*`, etc.) | Preserved in `props.htmlAttributes` on base container/text/link/button/image nodes and editable in the Properties panel Attributes view. `class` names become registry classes, inline `style` declarations become `node.inlineStyles`, event handlers are stripped, reserved editor/runtime `data-*` names are not imported, and attributes already owned by the module (for example `href` on links and `src` on images) stay in their first-class module props. An authored `rel` on a link or button anchor is kept in the bag; at render time the module merges it with the `noopener noreferrer` it adds for `target="_blank"` and emits one `rel` attribute. | The module schema owns modeled props; `htmlAttributes` is the safe escape hatch for extra authored attributes |
| Exact inline whitespace around mixed content (`<div>Hello <em>world</em></div>`) | Approximated | Each text run becomes a `base.text` child with `tag: 'none'` and whitespace collapsed to single spaces. True parent-edge indentation is trimmed, but a single boundary space is preserved around element siblings so `Hello <em>world</em>` does not become `Helloworld`. The text itself is **preserved** and publishes without an extra wrapper. |
| Whitespace-only text (newlines/indentation between tags) | Dropped in normal flow; preserved verbatim inside `<pre>` | Normal-flow indentation carries no rendered content, while `<pre>` whitespace is content and must retain its literal DOM text-node shape for CSS and runtime scripts. |
| Void elements (`<br>`, `<hr>`, etc.) | Imported as a childless `base.container` node with `tag:'custom'` and the real tag name as `customTag`. No children, no empty-container placeholder. `<input>` imports as a form primitive instead. | React throws if children are rendered inside void element tags; the dedicated void-element rule (before the catch-all) sets `recurse:false` and the canvas renderer skips children entirely for void tags. |
Expand Down
2 changes: 1 addition & 1 deletion docs/features/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ src/modules/base/
├── slotOutlet/ — base.slot-outlet (VC author side)
├── slotInstance/ — base.slot-instance (VC consumer side)
├── shared/
│ └── anchorTarget.ts — AnchorTargetSchema, ANCHOR_TARGET_OPTIONS, anchorRel() (button + link)
│ └── anchorTarget.ts — AnchorTargetSchema, ANCHOR_TARGET_OPTIONS, anchorRel(), mergeAnchorRel(), anchorHtmlAttributes() (button + link)
├── utils/
│ ├── escape.ts — escapeHtml, safeUrl, sanitiseCssValue, buildStyle (re-exports publisher utils)
│ ├── htmlTag.ts — htmlTagControl, customHtmlTagControl (resolution lives in @core/htmlAttributes)
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/base-modules-shared-render.editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ describe('LinkEditor canvas DOM matches the shared helpers', () => {
expect(self.container.querySelector('a')?.getAttribute('rel')).toBe(null)
})

it('merges an authored rel with the security rel exactly as the publisher does', () => {
const blank = renderEditor(LinkModule, { href: 'https://e.com', target: '_blank', htmlAttributes: { rel: 'nofollow' } })
const published = LinkModule.render({ ...LinkModule.defaults, href: 'https://e.com', target: '_blank', htmlAttributes: { rel: 'nofollow' } }, []).html
expect(blank.container.querySelector('a')?.getAttribute('rel')).toBe('nofollow noopener noreferrer')
expect(published).toContain('rel="nofollow noopener noreferrer"')

const self = renderEditor(LinkModule, { href: '/page/2/', target: '_self', htmlAttributes: { rel: 'next' } })
expect(self.container.querySelector('a')?.getAttribute('rel')).toBe('next')
})

it('renders children when present, falls back to text when empty (== linkUsesChildren)', () => {
const withChildren = renderEditor(
LinkModule,
Expand Down Expand Up @@ -75,6 +85,13 @@ describe('ButtonEditor canvas DOM matches resolveButtonAnchor', () => {
const blank = renderEditor(ButtonModule, { href: 'https://e.com', target: '_blank', label: 'Go' })
expect(blank.container.querySelector('a')?.getAttribute('rel')).toBe(anchorRel('_blank'))
})

it('merges an authored rel with the security rel exactly as the publisher does', () => {
const blank = renderEditor(ButtonModule, { href: 'https://e.com', target: '_blank', label: 'Go', htmlAttributes: { rel: 'sponsored' } })
const published = ButtonModule.render({ ...ButtonModule.defaults, href: 'https://e.com', target: '_blank', label: 'Go', htmlAttributes: { rel: 'sponsored' } }, []).html
expect(blank.container.querySelector('a')?.getAttribute('rel')).toBe('sponsored noopener noreferrer')
expect(published).toContain('rel="sponsored noopener noreferrer"')
})
})

describe('ListEditor canvas DOM matches parseItems', () => {
Expand Down
48 changes: 47 additions & 1 deletion src/__tests__/base-modules-shared-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { ButtonModule } from '@modules/base/button'
import { ListModule } from '@modules/base/list'
import { VideoModule } from '@modules/base/video'

import { anchorRel, ANCHOR_TARGET_OPTIONS } from '@modules/base/shared/anchorTarget'
import { anchorRel, mergeAnchorRel, ANCHOR_TARGET_OPTIONS } from '@modules/base/shared/anchorTarget'
import { linkUsesChildren } from '@modules/base/link/content'
import { resolveButtonAnchor } from '@modules/base/button/anchor'
import { parseItems } from '@modules/base/list/items'
Expand Down Expand Up @@ -62,6 +62,52 @@ describe('anchorRel — single source for the noopener rule', () => {
})
})

describe('mergeAnchorRel — one rel attribute, authored tokens plus the security rel', () => {
it('keeps authored tokens, always adds the _blank guard, and emits each token once', () => {
expect(mergeAnchorRel(undefined, '_self')).toBeNull()
expect(mergeAnchorRel('', '_self')).toBeNull()
expect(mergeAnchorRel('nofollow', '_self')).toBe('nofollow')
expect(mergeAnchorRel(' next prev ', '_parent')).toBe('next prev')
expect(mergeAnchorRel(undefined, '_blank')).toBe(anchorRel('_blank'))
expect(mergeAnchorRel('nofollow', '_blank')).toBe('nofollow noopener noreferrer')
expect(mergeAnchorRel('noopener', '_blank')).toBe('noopener noreferrer')
expect(mergeAnchorRel('noreferrer sponsored', '_blank')).toBe('noreferrer sponsored noopener')
expect(mergeAnchorRel(42, '_blank')).toBe('noopener noreferrer')
})

const relAttributes = (html: string): string[] => [...html.matchAll(/\brel="([^"]*)"/g)].map((m) => m[1]!)

it('link and button render() emit exactly one rel, keeping the authored tokens', () => {
const cases = [
LinkModule.render({ ...LinkModule.defaults, href: '/page/2/', target: '_self', htmlAttributes: { rel: 'next' } }, []).html,
ButtonModule.render({ ...ButtonModule.defaults, href: '/page/2/', target: '_self', htmlAttributes: { rel: 'next' } }, []).html,
]
for (const html of cases) expect(relAttributes(html)).toEqual(['next'])
})

it('an authored rel never removes the noopener guard on a new-tab link or button', () => {
// Two rel attributes on one element is a parse error: the browser keeps
// the first. Before the merge the author's rel was emitted before the
// module's, so a nofollow new-tab link published without noopener.
const link = LinkModule.render({ ...LinkModule.defaults, href: 'https://e.com', target: '_blank', htmlAttributes: { rel: 'nofollow' } }, []).html
const button = ButtonModule.render({ ...ButtonModule.defaults, href: 'https://e.com', target: '_blank', htmlAttributes: { rel: 'nofollow' } }, []).html
for (const html of [link, button]) {
expect(relAttributes(html)).toEqual(['nofollow noopener noreferrer'])
}
const authoredGuard = LinkModule.render({ ...LinkModule.defaults, href: 'https://e.com', target: '_blank', htmlAttributes: { rel: 'noopener noreferrer' } }, []).html
expect(relAttributes(authoredGuard)).toEqual(['noopener noreferrer'])
})

it('escapes the authored rel and leaves the other bag attributes alone', () => {
const html = LinkModule.render(
{ ...LinkModule.defaults, href: 'https://e.com', target: '_self', htmlAttributes: { rel: 'a"b', 'data-track': 'x' } },
[],
).html
expect(html).toContain('rel="a&quot;b"')
expect(html).toContain('data-track="x"')
})
})

describe('linkUsesChildren — children-vs-text fallback', () => {
it('treats an empty children collection as "use text", not "use empty children"', () => {
expect(linkUsesChildren(0)).toBe(false)
Expand Down
22 changes: 21 additions & 1 deletion src/__tests__/htmlImport/mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,10 +1032,30 @@ describe('HTML attribute preservation — props.htmlAttributes for ordinary base

const children = container.children.map((id) => result.nodes[id]!)
expect(children[0]!.moduleId).toBe('base.link')
expect(children[0]!.props.htmlAttributes).toEqual({ 'data-track': 'jump' })
expect(children[0]!.props.htmlAttributes).toEqual({ rel: 'nofollow', 'data-track': 'jump' })
expect(children[1]!.moduleId).toBe('base.image')
expect(children[1]!.props.htmlAttributes).toEqual({ 'data-lazy': 'logo' })
})

it('keeps an authored rel on links and button anchors', () => {
// The modules regenerate only the security rel for target="_blank", so a
// source rel (nofollow, next/prev, sponsored) has no other home. A theme
// styling `a[rel="next"]` needs it to survive import.
const result = imported(`
<nav>
<a href="/page/2/" rel="next">Next</a>
<a class="btn" href="https://sponsor.example" target="_blank" rel="sponsored nofollow">Sponsor</a>
</nav>
`)

const nav = result.nodes[result.rootIds[0]!]!
const children = nav.children.map((id) => result.nodes[id]!)
expect(children[0]!.moduleId).toBe('base.link')
expect(children[0]!.props.htmlAttributes).toEqual({ rel: 'next' })
expect(children[1]!.moduleId).toBe('base.button')
expect(children[1]!.props.target).toBe('_blank')
expect(children[1]!.props.htmlAttributes).toEqual({ rel: 'sponsored nofollow' })
})
})

// ---------------------------------------------------------------------------
Expand Down
9 changes: 7 additions & 2 deletions src/core/htmlImport/walkAndMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,13 @@ const HTML_ATTRIBUTE_MODULES = new Set([
'base.form',
])

// `rel` is deliberately absent from the anchor modules: base.link and
// base.button only ever generate the security rel for `target="_blank"`, so a
// source-declared `rel` (nofollow, sponsored, next/prev, …) has no other home
// and would be lost outright. It stays in the bag and the modules merge it
// with the security rel at render time (`anchorHtmlAttributes`).
const MODULE_GENERATED_ATTRIBUTE_NAMES: Record<string, readonly string[]> = {
'base.button': ['aria-disabled', 'disabled', 'href', 'rel', 'target', 'type'],
'base.button': ['aria-disabled', 'disabled', 'href', 'target', 'type'],
'base.form': [
'action',
'data-instatic-form-id',
Expand All @@ -146,7 +151,7 @@ const MODULE_GENERATED_ATTRIBUTE_NAMES: Record<string, readonly string[]> = {
'style',
'width',
],
'base.link': ['href', 'rel', 'target'],
'base.link': ['href', 'target'],
}

/**
Expand Down
10 changes: 5 additions & 5 deletions src/modules/base/button/ButtonEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/
import React from 'react'
import type { ModuleComponentProps } from '@core/module-engine'
import { anchorRel } from '@modules/base/shared/anchorTarget'
import { anchorHtmlAttributes } from '@modules/base/shared/anchorTarget'
import { htmlAttributesForReact } from '@core/htmlAttributes'
import { inlineEditableElementProps } from '@modules/base/shared/inlineText'
import { resolveButtonAnchor } from './anchor'
Expand All @@ -23,19 +23,19 @@ export const ButtonEditor: React.FC<ModuleComponentProps<ButtonStoredProps>> = (
inlineEdit,
}) => {
const label = props.label || 'Button'
const htmlAttrs = htmlAttributesForReact(props.htmlAttributes)
const anchor = resolveButtonAnchor(props.href)
// React.createElement (not JSX) so the editable element's generic
// `Ref<HTMLElement>` is accepted — matching TextEditor / LinkEditor.
if (anchor) {
const { attributes, rel } = anchorHtmlAttributes(props.htmlAttributes, props.target)
return React.createElement(
'a',
{
...nodeWrapperProps,
...htmlAttrs,
...attributes,
href: anchor.href,
target: props.target,
rel: anchorRel(props.target) ?? undefined,
rel: rel ?? undefined,
className: mcClassName,
...(inlineEdit ? inlineEditableElementProps(inlineEdit) : {}),
},
Expand All @@ -46,7 +46,7 @@ export const ButtonEditor: React.FC<ModuleComponentProps<ButtonStoredProps>> = (
'button',
{
...nodeWrapperProps,
...htmlAttrs,
...htmlAttributesForReact(props.htmlAttributes),
type: 'button',
className: mcClassName,
// A disabled button can't be focused/edited — never disable while editing.
Expand Down
11 changes: 6 additions & 5 deletions src/modules/base/button/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import type { ModuleDefinition } from '@core/module-engine'
import { registry } from '@core/module-engine'
import { CursorClickSolidIcon } from 'pixel-art-icons/icons/cursor-click-solid'
import { Value } from '@core/utils/typeboxHelpers'
import { ANCHOR_TARGET_OPTIONS, anchorRel } from '@modules/base/shared/anchorTarget'
import { ANCHOR_TARGET_OPTIONS, anchorHtmlAttributes } from '@modules/base/shared/anchorTarget'
import {
htmlAttributesControl,
} from '@modules/base/shared/htmlAttributes'
import { htmlAttributesAttr } from '@core/publisher'
import { escapeHtml } from '@modules/base/utils/escape'
import { resolveButtonAnchor } from './anchor'
import { ButtonEditor } from './ButtonEditor'
import { ButtonPropsSchema, type ButtonStoredProps } from './props'
Expand Down Expand Up @@ -64,13 +65,13 @@ export const ButtonModule: ModuleDefinition<ButtonStoredProps> = {

render: (props) => {
const label = String(props.label ?? '')
const attrs = htmlAttributesAttr(props.htmlAttributes)
const anchor = resolveButtonAnchor(props.href)
if (anchor) {
const rel = anchorRel(props.target)
const relAttr = rel ? ` rel="${rel}"` : ''
return { html: `<a${attrs} href="${anchor.href}" target="${String(props.target)}"${relAttr}>${label}</a>` }
const { attributes, rel } = anchorHtmlAttributes(props.htmlAttributes, props.target)
const relAttr = rel ? ` rel="${escapeHtml(rel)}"` : ''
return { html: `<a${htmlAttributesAttr(attributes)} href="${anchor.href}" target="${String(props.target)}"${relAttr}>${label}</a>` }
}
const attrs = htmlAttributesAttr(props.htmlAttributes)
const disabledAttr = props.disabled ? ' disabled aria-disabled="true"' : ''
const buttonType = props.buttonType === 'reset' ? 'reset' : 'button'
return { html: `<button${attrs} type="${buttonType}"${disabledAttr}>${label}</button>` }
Expand Down
8 changes: 4 additions & 4 deletions src/modules/base/link/LinkEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
*/
import React from 'react'
import type { ModuleComponentProps } from '@core/module-engine'
import { anchorRel } from '@modules/base/shared/anchorTarget'
import { htmlAttributesForReact } from '@core/htmlAttributes'
import { anchorHtmlAttributes } from '@modules/base/shared/anchorTarget'
import { inlineEditableElementProps } from '@modules/base/shared/inlineText'
import { linkUsesChildren } from './content'
import type { LinkStoredProps } from './props'
Expand All @@ -20,14 +19,15 @@ export const LinkEditor: React.FC<ModuleComponentProps<LinkStoredProps>> = ({ pr
// Inline editing only starts on a childless link (text mode), so when
// `inlineEdit` is set the element edits its `text` prop in place.
const content = linkUsesChildren(childCount) ? children : (props.text ?? 'Link text')
const { attributes, rel } = anchorHtmlAttributes(props.htmlAttributes, props.target)
return React.createElement(
'a',
{
...nodeWrapperProps,
...htmlAttributesForReact(props.htmlAttributes),
...attributes,
href: props.href || '#',
target: props.target,
rel: anchorRel(props.target) ?? undefined,
rel: rel ?? undefined,
className: mcClassName,
...(inlineEdit ? inlineEditableElementProps(inlineEdit) : {}),
},
Expand Down
10 changes: 5 additions & 5 deletions src/modules/base/link/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
import type { ModuleDefinition } from '@core/module-engine'
import { registry } from '@core/module-engine'
import { LinkIcon } from 'pixel-art-icons/icons/link'
import { safeUrl } from '@modules/base/utils/escape'
import { escapeHtml, safeUrl } from '@modules/base/utils/escape'
import { Value } from '@core/utils/typeboxHelpers'
import { ANCHOR_TARGET_OPTIONS, anchorRel } from '@modules/base/shared/anchorTarget'
import { ANCHOR_TARGET_OPTIONS, anchorHtmlAttributes } from '@modules/base/shared/anchorTarget'
import {
htmlAttributesControl,
} from '@modules/base/shared/htmlAttributes'
Expand Down Expand Up @@ -52,9 +52,9 @@ export const LinkModule: ModuleDefinition<LinkStoredProps> = {

render: (props, renderedChildren) => {
const href = safeUrl(props.href)
const attrs = htmlAttributesAttr(props.htmlAttributes)
const rel = anchorRel(props.target)
const relAttr = rel ? ` rel="${rel}"` : ''
const { attributes, rel } = anchorHtmlAttributes(props.htmlAttributes, props.target)
const attrs = htmlAttributesAttr(attributes)
const relAttr = rel ? ` rel="${escapeHtml(rel)}"` : ''
const targetAttr = ` target="${String(props.target)}"`
const content = linkUsesChildren(renderedChildren.length)
? renderedChildren.join('')
Expand Down
Loading
Loading