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
5 changes: 3 additions & 2 deletions docs/features/html-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ Callers splice the fragment into the page tree via `insertImportedNodes(parentId
| `instatic-outlet` | `base.outlet` | none (the CMS content outlet) | **No** |
| `instatic-loop` | `base.loop` | `sourceId`, `filters.tableId`, `orderBy`, `direction`, `limit`, `offset`, `pagination`, `pageSize`, optional `tag` / `customTag` from `data-*` attrs | Yes |
| `h1`–`h6`, `p`, `span`, `small`, `strong`, `em` | `base.text` | `text` = `el.textContent`, `tag` = tag name | No |
| `a` with class `btn` | `base.button`, or `base.link` when it wraps element children | `label` (`text` on `base.link`) = `el.textContent`, `href`, `target` | No for text-only; yes when it wraps elements |
| `a` (no `btn` class) | `base.link` | `text` = `el.textContent`, `href`, `target` | No for text-only; yes when it wraps elements |
| `a` with class `btn` | `base.button`, or `base.link` when it wraps element children | `label` (`text` on `base.link`) = `el.textContent`, `href`, `target` (normalised, see below) | No for text-only; yes when it wraps elements |
| `a` (no `btn` class) | `base.link` | `text` = `el.textContent`, `href`, `target` (normalised, see below) | No for text-only; yes when it wraps elements |
| `img` | `base.image` | `src`, plus `loading` / `decoding` / `fetchPriority` when the attribute holds a value the module offers; `alt` is reported in `imageAlts` for the media record, not stored as a prop | No |
| `form` | `base.form` | `mode`, `formId`, CMS data attrs, custom `action` / `method` | Yes |
| `label` | `base.label` unless wrapping elements, then `base.container` | `text`, `targetMode`, `targetId` | No for plain labels; yes for wrapper labels |
Expand All @@ -122,6 +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.
- **`target` is normalised to the module vocabulary.** `base.link` and `base.button` persist only `_self`, `_blank` and `_parent` (`AnchorTargetSchema` in `@core/htmlAttributes`). Other authored values map to the one that navigates the same way outside a frameset: an empty `target=""`, a bare `target` and `_top` import as `_self`; a named browsing context (`target="sidebar"`, `_new`), which opens a new tab when no frame has that name, imports as `_blank`. Keywords match case-insensitively. Copying it verbatim would leave a prop the publisher's schema rejects, and the whole node would then render with module defaults instead of its authored `href` and text.
- **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.
- `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.
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 — ANCHOR_TARGET_OPTIONS, anchorRel() (button + link; schema in @core/htmlAttributes)
├── utils/
│ ├── escape.ts — escapeHtml, safeUrl, sanitiseCssValue, buildStyle (re-exports publisher utils)
│ ├── htmlTag.ts — htmlTagControl, customHtmlTagControl (resolution lives in @core/htmlAttributes)
Expand Down
5 changes: 3 additions & 2 deletions docs/reference/module-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ Name the leaf after what it owns, not generically:

| File | Exports | Used by |
|----------------------------|--------------------------------------------------------------|--------------------|
| `shared/anchorTarget.ts` | `AnchorTargetSchema`, `AnchorTarget`, `ANCHOR_TARGET_OPTIONS`, `anchorRel()` | button, link |
| `shared/anchorTarget.ts` | `ANCHOR_TARGET_OPTIONS`, `anchorRel()` | button, link |

```ts
// anchor.ts — leaf file for base.button
Expand Down Expand Up @@ -421,7 +421,8 @@ The publisher emits a `<script type="importmap">` entry. `getMissingModuleDepend
- `src/core/publisher/renderConfig.ts` — `RenderResolvedMedia` shape
- `src/modules/base/*` — first-party modules (read these for real examples)
- `src/modules/base/container/ContainerEditor.tsx` — canonical editor component pattern
- `src/modules/base/shared/anchorTarget.ts` — `AnchorTargetSchema`, `anchorRel()` (cross-module shared vocabulary)
- `src/modules/base/shared/anchorTarget.ts` — `ANCHOR_TARGET_OPTIONS`, `anchorRel()` (cross-module shared vocabulary)
- `src/core/htmlAttributes/anchorTarget.ts` — `AnchorTargetSchema`, `normalizeAnchorTarget()` (persisted `target` vocabulary, shared with the HTML importer)
- `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/list/items.ts` — `parseItems()` (per-module shared leaf)
Expand Down
50 changes: 50 additions & 0 deletions src/__tests__/htmlImport/mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,38 @@ describe('base.link — plain <a> elements', () => {
expect(node.props.target).toBe('_self')
})

it('plain anchor with target="" → "_self", href and text kept', () => {
const node = single('<a href="/contact/" target="">Contact us</a>')
expect(node.props.target).toBe('_self')
expect(node.props.href).toBe('/contact/')
expect(node.props.text).toBe('Contact us')
})

it('plain anchor with a bare target attribute → "_self"', () => {
const node = single('<a href="/contact/" target>Contact us</a>')
expect(node.props.target).toBe('_self')
})

it('plain anchor with target="_top" → "_self"', () => {
const node = single('<a href="/" target="_top">Home</a>')
expect(node.props.target).toBe('_self')
})

it('plain anchor with a named target → "_blank" (a new tab when no frame has that name)', () => {
const node = single('<a href="/" target="content-frame">Home</a>')
expect(node.props.target).toBe('_blank')
})

it('plain anchor keeps target="_parent"', () => {
const node = single('<a href="/" target="_parent">Up</a>')
expect(node.props.target).toBe('_parent')
})

it('plain anchor reads target keywords case-insensitively', () => {
const node = single('<a href="/" target="_BLANK">Open</a>')
expect(node.props.target).toBe('_blank')
})

it('plain anchor with empty href → href is empty string', () => {
const node = single('<a href="">Empty</a>')
expect(node.props.href).toBe('')
Expand Down Expand Up @@ -239,6 +271,24 @@ describe('base.button — <a class="btn"> elements', () => {
expect(node.props.target).toBe('_self')
})

it('<a class="btn"> with target="" → "_self", href and label kept', () => {
const node = single('<a class="btn" href="/buy" target="">Buy now</a>')
expect(node.props.target).toBe('_self')
expect(node.props.href).toBe('/buy')
expect(node.props.label).toBe('Buy now')
})

it('<a class="btn"> with target="_top" → "_self", a named target → "_blank"', () => {
expect(single('<a class="btn" href="/x" target="_top">X</a>').props.target).toBe('_self')
expect(single('<a class="btn" href="/x" target="_new">X</a>').props.target).toBe('_blank')
})

it('compound <a class="btn"> (→ base.link) normalises target too', () => {
const node = single('<a class="btn" href="/x" target=""><span>X</span></a>')
expect(node.moduleId).toBe('base.link')
expect(node.props.target).toBe('_self')
})

it('<a class="btn primary"> — btn is present among other classes → still base.button', () => {
const node = single('<a class="btn primary large" href="/x">CTA</a>')
expect(node.moduleId).toBe('base.button')
Expand Down
41 changes: 41 additions & 0 deletions src/core/htmlAttributes/anchorTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* The anchor-`target` vocabulary the base modules persist (`base.link` and
* `base.button`), and the one place that maps authored HTML onto it.
*
* Lives core-side because both the block catalogue (the schema each module
* embeds) and the engine (the HTML importer) need it, and `src/core/` never
* imports `src/modules/`. The module-specific pieces — the Properties-panel
* select options and the `rel` decision — stay in
* `@modules/base/shared/anchorTarget`.
*/
import { Type, type Static } from '@core/utils/typeboxHelpers'

export const AnchorTargetSchema = Type.Union(
[Type.Literal('_self'), Type.Literal('_blank'), Type.Literal('_parent')],
{ default: '_self' },
)

export type AnchorTarget = Static<typeof AnchorTargetSchema>

const ANCHOR_TARGETS: ReadonlySet<string> = new Set(
AnchorTargetSchema.anyOf.map((literal) => literal.const),
)

/**
* Map a raw HTML `target` attribute onto the persisted vocabulary.
*
* Authored markup carries values the schema does not model, and none of them
* can be stored as-is: `AnchorTargetSchema` would reject the node's props at
* publish time. Each maps to the stored value that navigates the same way
* outside a frameset. An empty string (`target=""` or a bare `target`) and
* `_top` open in the same tab, so they become `_self`. Any other value names
* a browsing context; with no frame of that name the browser opens a new tab,
* so it becomes `_blank`. HTML matches the keywords ASCII case-insensitively,
* so `_BLANK` is still `_blank`.
*/
export function normalizeAnchorTarget(raw: string | null | undefined): AnchorTarget {
if (raw === null || raw === undefined) return '_self'
const keyword = raw.toLowerCase()
if (ANCHOR_TARGETS.has(keyword)) return keyword as AnchorTarget
return keyword === '' || keyword === '_top' ? '_self' : '_blank'
}
1 change: 1 addition & 0 deletions src/core/htmlAttributes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
normalizeHtmlAttributes,
sanitizeRenderableHtmlAttribute,
} from './attributes'
export { AnchorTargetSchema, normalizeAnchorTarget, type AnchorTarget } from './anchorTarget'
export {
BUILTIN_HTML_TAGS,
CUSTOM_HTML_TAG_VALUE,
Expand Down
10 changes: 8 additions & 2 deletions src/core/htmlImport/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import { normalizeImportedText } from './text'
import { normalizeIdentifierValue } from '@core/utils/identifier'
import { normalizeAnchorTarget } from '@core/htmlAttributes'

export interface ImportRule {
/** CSS selector tested via `el.matches()`. */
Expand Down Expand Up @@ -388,12 +389,17 @@ export const HTML_TO_MODULE_RULES: ImportRule[] = [
// treatment the plain anchor rule below gives a compound `<a>` — which keeps
// href and target. The `btn` class rides along as a classId, so the swap does
// not change how the element is styled.
//
// `target` is normalised, not copied: an empty attribute, `_top` or a named
// frame is not in `AnchorTargetSchema`, and a value outside the schema makes
// the publisher discard the node's props wholesale. `normalizeAnchorTarget`
// maps each onto the stored value that navigates the same way.
{
match: 'a.btn',
map: (el) => {
const text = normalizeImportedText(el.textContent ?? '')
const href = el.getAttribute('href') ?? ''
const target = el.getAttribute('target') ?? '_self'
const target = normalizeAnchorTarget(el.getAttribute('target'))

return hasElementChild(el)
? { moduleId: 'base.link', props: { text, href, target } }
Expand All @@ -413,7 +419,7 @@ export const HTML_TO_MODULE_RULES: ImportRule[] = [
props: {
text: normalizeImportedText(el.textContent ?? ''),
href: el.getAttribute('href') ?? '',
target: el.getAttribute('target') ?? '_self',
target: normalizeAnchorTarget(el.getAttribute('target')),
},
}),
recurse: hasElementChild,
Expand Down
2 changes: 1 addition & 1 deletion src/modules/base/button/props.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Type, type Static } from '@core/utils/typeboxHelpers'
import { AnchorTargetSchema } from '@modules/base/shared/anchorTarget'
import { AnchorTargetSchema } from '@core/htmlAttributes'
import { HtmlAttributesPropSchemaOptions } from '@modules/base/shared/htmlAttributes'

export const ButtonPropsSchema = Type.Object({
Expand Down
2 changes: 1 addition & 1 deletion src/modules/base/link/props.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Type, type Static } from '@core/utils/typeboxHelpers'
import { AnchorTargetSchema } from '@modules/base/shared/anchorTarget'
import { AnchorTargetSchema } from '@core/htmlAttributes'
import { HtmlAttributesPropSchemaOptions } from '@modules/base/shared/htmlAttributes'

export const LinkPropsSchema = Type.Object({
Expand Down
28 changes: 11 additions & 17 deletions src/modules/base/shared/anchorTarget.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,23 @@
/**
* Shared anchor-`target` vocabulary for the base modules that emit `<a>`
* elements (`base.link` and `base.button`).
* Shared anchor-`target` UI and `rel` logic for the base modules that emit
* `<a>` elements (`base.link` and `base.button`). The persisted vocabulary
* itself (`AnchorTargetSchema`, `normalizeAnchorTarget`) lives in
* `@core/htmlAttributes` so the HTML importer can share it without a
* core→modules import.
*
* Both modules used to redeclare an identical `Type.Union([_self, _blank,
* _parent])` schema, an identical select-options array, AND an identical
* `rel="noopener noreferrer"` rule — once in the publisher `render()` path and
* again in the canvas `*Editor.tsx`. Four copies of the rel logic meant four
* places for the canvas and the published page to drift apart. They now share
* this one leaf:
* Both modules used to redeclare an identical select-options array AND an
* identical `rel="noopener noreferrer"` rule — once in the publisher
* `render()` path and again in the canvas `*Editor.tsx`. Four copies of the
* rel logic meant four places for the canvas and the published page to drift
* apart. They now share this one leaf:
*
* - `AnchorTargetSchema` / `AnchorTarget` — the persisted prop shape.
* - `ANCHOR_TARGET_OPTIONS` — the Properties-panel select.
* - `anchorRel(target)` — the single rel decision.
*
* Lives in a non-component `.ts` so the editor components can import it without
* breaking React Fast Refresh (Constraint #309).
*/
import { Type, type Static } from '@core/utils/typeboxHelpers'

export const AnchorTargetSchema = Type.Union(
[Type.Literal('_self'), Type.Literal('_blank'), Type.Literal('_parent')],
{ default: '_self' },
)

type AnchorTarget = Static<typeof AnchorTargetSchema>
import type { AnchorTarget } from '@core/htmlAttributes'

/** Select options for the Properties-panel `target` control. */
export const ANCHOR_TARGET_OPTIONS: ReadonlyArray<{ label: string; value: AnchorTarget }> = [
Expand Down
Loading