From bbf731e433d9c0408f536f729ad9e31dc5cd8acd Mon Sep 17 00:00:00 2001 From: tommy230 Date: Mon, 28 Sep 2026 19:44:58 +0000 Subject: [PATCH] fix(import): map unsupported anchor targets to _self or _blank The anchor import rules copied target="" (and _top or a named frame) straight into the node. None of those values is in AnchorTargetSchema, so the publisher's fallback replaced the whole link with module defaults and the page shipped "Click here" pointing at "#". The schema now lives in @core/htmlAttributes with a normalizeAnchorTarget() helper the importer uses for both anchor rules: empty and _top import as _self, a named target as _blank, keywords match case-insensitively, and href and text are kept. Co-Authored-By: Claude Opus 5.5 --- docs/features/html-import.md | 5 ++- docs/features/modules.md | 2 +- docs/reference/module-engine.md | 5 ++- src/__tests__/htmlImport/mapping.test.ts | 50 ++++++++++++++++++++++++ src/core/htmlAttributes/anchorTarget.ts | 41 +++++++++++++++++++ src/core/htmlAttributes/index.ts | 1 + src/core/htmlImport/rules.ts | 10 ++++- src/modules/base/button/props.ts | 2 +- src/modules/base/link/props.ts | 2 +- src/modules/base/shared/anchorTarget.ts | 28 ++++++------- 10 files changed, 120 insertions(+), 26 deletions(-) create mode 100644 src/core/htmlAttributes/anchorTarget.ts diff --git a/docs/features/html-import.md b/docs/features/html-import.md index 7cb7bf493..831914e82 100644 --- a/docs/features/html-import.md +++ b/docs/features/html-import.md @@ -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 | @@ -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 `
98%
` and `
  • Buy milk
  • ` 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. - **`` metadata is preserved separately.** Classes, safe HTML attributes (`id`, ARIA, `data-*`, etc.), and harvested inline styles on `` 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 `