name: evo-migrate-marko description: Migrate a component from @ebay/ebayui-core (Marko 5) to @evo-web/marko (Marko 6). Receives the ebayui-core component name as the argument (e.g. /evo-migrate-marko ebay-button).
Migrate ebayui-core (Marko 5) to evo-marko (Marko 6)
You are migrating $ARGUMENTS from packages/ebayui-core/src/components/$ARGUMENTS/ to a new packages/evo-marko/src/tags/evo-${ARGUMENTS#ebay-}/ directory.
This is an ACTIVE PROCESS, before making any decision you should consult with the user. Especially important for user-facing decisions like APIs.
evo-marko is expected to have breaking changes from ebayui-core. If something seems unnecessary or could be simplified at the API level talk to the user about pruning it.
Step 0 --- Read before writing
- Read every file in
packages/ebayui-core/src/components/$ARGUMENTS/including:index.marko(template)component.tsorcomponent-browser.ts(JS class)style.ts(Skin CSS imports)marko-tag.json(attribute definitions)- All files in
examples/subdirectory
- Read 2--3 already-migrated evo-marko components of similar complexity for reference patterns:
- Simple (no JS):
packages/evo-marko/src/tags/evo-signal/index.marko - Simple (with events):
packages/evo-marko/src/tags/evo-switch/index.marko - Medium (floating label + state):
packages/evo-marko/src/tags/evo-input/index.marko - Complex (roving tabindex + children):
packages/evo-marko/src/tags/evo-tabs/index.marko - Complex (floating UI positioning):
packages/evo-marko/src/tags/evo-tooltip/index.marko
- Simple (no JS):
- If the Marko 5 component uses shared base classes or utilities from
src/common/, read those too. - If the Marko 5 component uses
makeup-*libraries, check whether an evo-marko headless replacement exists:makeup-roving-tabindex/makeup-active-descendant-><evo-roving-tabindex>(insrc/tags/tags/)makeup-expander+@floating-ui/dom-><evo-expander>(insrc/tags/tags/)makeup-floating-label-><evo-floating-label>(insrc/tags/tags/)makeup-typeahead-><evo-typeahead>(insrc/tags/tags/)
Naming conventions
| ebayui-core (Marko 5) | evo-marko (Marko 6) |
|---|---|
ebay-button (dir) |
evo-button (dir) |
src/components/ebay-* |
src/tags/evo-* |
<ebay-button> |
<evo-button> |
<ebay-chevron-down-12-icon> |
<evo-icon-chevron-down-12> |
component-browser.ts |
(none -- inline in template) |
component.ts |
(none -- inline in template) |
marko-tag.json |
(none -- types in template) |
browser.json |
(none -- not used) |
File structure
packages/evo-marko/src/tags/evo-{name}/
index.marko <-- single-file component (template + types + logic)
style.ts <-- Skin CSS imports (same as Marko 5)
README.md <-- component docs
{name}.stories.ts <-- Storybook stories
examples/
default.marko <-- minimal usage example
*.marko <-- additional examples
test/
test.browser.ts <-- browser interaction tests
test.server.ts <-- SSR snapshot tests
Key difference from ebayui-core: Everything lives in index.marko. There is no separate component.ts, component-browser.ts, or marko-tag.json.
README format
Minimal only — no props tables, no usage examples, no extra sections.
Reference: packages/evo-marko/src/tags/evo-chip/README.md
<h1 style="display: flex; justify-content: space-between; align-items: center;">
<span> evo-[name] </span>
<span style="font-weight: normal; font-size: medium; margin-bottom: -15px;">
DS v1.0.0
</span>
</h1>
One-line description. ## Examples and Documentation - [Storybook](...) -
[Storybook Docs](...) - [Code Examples](...)
Template syntax migration rules
These are the critical Marko 5 -> Marko 6 syntax transformations. Apply them systematically.
Scriptlets -> Tag variables
// Marko 5 -- $ scriptlet
$ const { class: inputClass, size, ...htmlInput } = input;
$ var isLarge = size === "large";
// Marko 6 -- <const/> and <let/> tags
<const/{ class: inputClass, size, ...htmlInput }=input>
<const/isLarge=(size === "large")>
Use <const/> for immutable values, <let/> for mutable state.
Conditionals
// Marko 5
<if(condition)>
...
</if>
<else-if(condition)>
...
</else-if>
<else>...</else>
// Marko 6
<if=condition>
...
</if>
<else-if=condition>
...
</else-if>
<else>...</else>
Type declarations and Input interface
// Marko 5 -- static block + WithNormalizedProps wrapper
import type { WithNormalizedProps } from "../../global";
static {
interface SomeInput extends Omit<Marko.HTML.Span, `on${string}`> {
"my-prop"?: string;
"on-change"?: (e: SomeEvent) => void;
}
}
export interface Input extends WithNormalizedProps<SomeInput> {}
// Marko 6 -- direct export, camelCase props, no event stripping
export interface Input extends Marko.HTML.Span {
myProp?: string;
}
Key changes:
- Remove
WithNormalizedProps-- Marko 6 does not need it. - Remove
Omit<..., 'on${string}'>-- Marko 6 handles events natively via spread. - Remove custom
on-*event props from the interface -- consumers bind native DOM events directly or use two-way binding callbacks (e.g.,valueChange,openChange,indexChange). - Use camelCase for all prop names (not kebab-case).
- Use
Marko.HTML.*for element types -- e.g.,Marko.HTML.H2,Marko.HTML.Div,Marko.HTML.Button. Do not useMarko.Input<"h2">orMarko.Input<"div">. - Omit only props the component hardcodes (e.g.,
Omit<Marko.HTML.Input, "type" | "role">).
HTML attribute pass-through
// Marko 5 -- processHtmlAttributes helper
import { processHtmlAttributes } from "../../common/html-attributes";
<div ...processHtmlAttributes(htmlInput)>
// Marko 6 -- native spread (no helper needed)
<div ...htmlInput>
Drop the processHtmlAttributes import entirely. Marko 6 handles attribute normalization natively.
Event handling
// Marko 5 -- string handler refs + component class
<button onClick("handleClick") onKeydown("handleKeydown")>
// In component-browser.ts:
handleClick(originalEvent: MouseEvent) {
this.emit("click", { originalEvent });
}
// Marko 6 -- inline handlers or native pass-through
// Option A: pure pass-through (preferred for simple components)
<button ...htmlInput> // onChange, onClick etc. come through the spread
// Option B: intercept + forward (when component needs to react)
<button
onClick(e, el) {
// component logic here
onClick && onClick(e, el);
}>
Eliminate custom event wrapping. In Marko 5, events are wrapped in { originalEvent, value, checked } objects via this.emit(). In Marko 6, pass native DOM events through directly. Consumers bind native handlers (onChange, onClick, etc.).
State management
// Marko 5 -- class-based state
// In component.ts:
interface State { selectedIndex: number; }
class Foo extends Marko.Component<Input, State> {
onCreate() { this.state = { selectedIndex: 0 }; }
onInput(input: Input) { this.state.selectedIndex = findIndex(input); }
}
// In index.marko:
<div hidden=!state.selectedIndex>
// Marko 6 -- reactive <let/> tags with two-way binding
<let/selectedIndex:=input.selectedIndex> // two-way bindable from parent
<div hidden=!selectedIndex>
The := syntax creates two-way binding. The parent can pass selectedIndex and selectedIndexChange to control the value, or leave it uncontrolled. This replaces the Marko 5 pattern of onInput + state + this.emit("event").
ID generation
// Marko 5 -- component.getElId() or id:scoped
$ var id = input.id || component.getElId("textbox");
<div id:scoped=`panel-${i}`>
// Marko 6 -- <id/> tag
<id/textboxId=input.id> // uses input.id if provided, otherwise auto-generates
<id/panelId>
<div id=`${panelId}-${i}`>
Element refs
// Marko 5 -- key + this.getEl()
<div key="menu">
// In component.ts:
this.getEl("menu") as HTMLElement
// Marko 6 -- tag variable on element
<div/$menuEl>
// Reference directly as $menuEl in template or <script> blocks
Lifecycle hooks -> <script> blocks
// Marko 5 -- component lifecycle
class {
onMount() {
this._setup();
}
onUpdate() {
this._setup();
}
onDestroy() {
this._cleanup();
}
}
// Marko 6 -- <script> with $signal for cleanup
// WARNING: `<script>` should be used _very rarely_, usually `<let>`/`<const>` or other Marko features are better
script --
// runs on mount and re-runs on dependency changes
setupSomething();
$signal.onabort = () => {
cleanupSomething();
};
<script> blocks run client-side. $signal is an AbortSignal that fires when the effect re-runs or the component unmounts. Use it for cleanup.
renderBody -> content
// Marko 5
<${input.renderBody}/>
<${tab.renderBody}/>
// Marko 6
<${input.content}/>
<${tab.content}/>
In Marko 6, the body of an attribute tag is accessed via .content (not .renderBody). For the root component body, it's input.content. For self-closing elements that should render their children, use spread: <span ...htmlInput/> -- this passes content through automatically.
MakeupJS library replacement
Replace imperative MakeupJS usage with declarative evo-marko headless components:
Roving tabindex
// Marko 5 -- imperative makeup-roving-tabindex
// component.ts:
import { createLinear } from "makeup-roving-tabindex";
onMount() {
this.rovingTabindex = createLinear(this.getEl("tabs"), ".tabs__item", {
index: state.selectedIndex, wrap: true,
});
}
onDestroy() { this.rovingTabindex.destroy(); }
// Marko 6 -- declarative <evo-roving-tabindex>
<evo-roving-tabindex/rovingTabIndex
autoSelect=activation === "auto"
nodeList=$item
selected:=index/>
// Use in template:
<div/$item
onClick(e, target) { rovingTabIndex.onClick(i); }
onKeyDown(e, target) { rovingTabIndex.onKeyDown(e); }
tabindex=(rovingTabIndex.isFocused(i) ? 0 : -1)>
The <evo-roving-tabindex> tag variable exposes: onClick(index), onKeyDown(event), isFocused(index), setFocusIndex(index).
Floating UI positioning (tooltips, popovers)
// Marko 5 -- imperative with makeup-expander + @floating-ui/dom
// Spread across component.ts + ebay-tooltip-base
// Marko 6 -- declarative <evo-expander>
<evo-expander/expander
open=open
placement=placement
offset=offset
flip=flip
shift=shift
inline=inline
strategy="absolute"
host=$host
overlay=$overlay
arrow=$arrow/>
<input/$host>
<span/$overlay style=expander.floatingStyles>
<span/$arrow style=expander.arrowStyles/>
</span>
The <evo-expander> tag variable exposes: floatingStyles, arrowStyles, ariaExpanded.
Custom event emission -> controllable pattern
// Marko 5 -- this.emit pattern
this.emit("select", { selectedIndex })
// Consumer: <ebay-tabs on-select("handler")>
// Marko 6 -- callback prop + controllable pattern
// In component: the <let/:=> handles this automatically
let/index:=input.index
// Consumer: <evo-tabs index:=myIndex>
// Or: <evo-tabs index=5 indexChange=handleChange>
The naming convention for change callbacks is {propName}Change (e.g., indexChange, openChange, valueChange).
static blocks
// Marko 5 -- compile-time code in static blocks
static var validSizes = ["large", "small"] as const;
static function isGroup(v) {
return !!v.optgroup;
}
// Marko 6 -- same syntax, still works
static function isGroup(v: Option | OptGroup): v is OptGroup {
return !!v.optgroup;
}
static blocks work the same way in Marko 6. Use them for compile-time constants and type guard functions.
toJSON hack removal
// Marko 5 -- prevents serialization bugs
static function noop() {}
$ (input as any).toJSON = noop;
// Marko 6 -- not needed, remove entirely
<subscribe> tag removal
// Marko 5 -- global event listener
<subscribe to=document on-keydown("handleKeydown")/>
// Marko 6 -- use <script> block with addEventListener
<script>
document.addEventListener("keydown", handleEscape, {
signal: $signal,
});
</script>
Polymorphic elements -> <define> pattern
When a Marko 5 component uses a dynamic tag to switch between element types (e.g., <a> vs <button>), replace it with a <define> block in Marko 6. The <define> should own all branch-specific attributes, and the call site should only pass shared attributes.
// Marko 5 -- dynamic tag with isButton guards everywhere
$ var isButton = type === "button";
<${isButton ? "button" : "a"}
...htmlItem
class="my-item"
disabled=isButton && disabled
aria-disabled=!isButton && disabled && "true"
href=!isButton && href
type=isButton && type>
...
</>
// Marko 6 -- <define> with branch-specific attributes inside each branch
<define/ItemTag|itemInput: Item|>
<const/{ type, disabled, href, ...rest }=itemInput>
<if=type === "button" || type === "submit">
<button ...rest as Marko.HTML.Button type=type disabled=disabled/>
</if>
<else>
<a
...rest as Marko.HTML.A
href=(disabled ? undefined : href)
aria-disabled=disabled && "true"/>
</else>
</define>
// Call site is clean -- only shared attributes
<ItemTag ...htmlItem class="my-item" aria-current=ariaCurrent>
...
</ItemTag>
Key principles:
- Each branch owns its element-specific attributes.
typeanddisabledonly appear on<button>,hrefandaria-disabledonly on<a>. This eliminatesisButton-style guards from the call site entirely. - Destructure branch-specific props inside the
<define>, spread the rest onto the element, and cast withas Marko.HTML.Button/as Marko.HTML.A. - See
evo-filter-chip/index.markofor a production reference of this pattern.
Attribute tags (@tag)
In Marko 5, attribute tags like @option, @item, @panel are defined in marko-tag.json. In Marko 6, they're defined as TypeScript types in the Input interface.
// Marko 5 marko-tag.json:
// "@item <item>[]": { "text": "string", "value": "string" }
// Marko 6 Input interface:
export interface Item extends Marko.HTML.Div {
panel?: Marko.AttrTag<Marko.HTML.Div>; // nested attr tag
}
export interface Input extends Marko.HTML.Div {
item?: Marko.AttrTag<Item>; // repeatable attr tag (iterable)
tab?: Marko.AttrTag<Tab>;
}
Consumer usage is the same: <@item>content</@item>.
For polymorphic content (host/heading that can be a different tag), use:
export interface Input extends Marko.HTML.Span {
host: Marko.AttrTag<Marko.HTML.Span & { as?: Marko.Renderable }>;
}
// Usage in template:
<const/{ as: hostAs = "span", class: hostClass, ...htmlHost }=host>
<${hostAs} ...htmlHost class=["tooltip__host", hostClass]/>
Style
style.ts is identical between Marko 5 and Marko 6. Copy it directly.
Storybook stories
The items in the storybook for eBayUI should not be trusted fully. Table keys should be generated FIRST from the TS Input definition and only descriptions should be pulled from Marko 5.
Pattern differences
| Aspect | Marko 5 (ebayui-core) | Marko 6 (evo-marko) |
|---|---|---|
| Story creation | Manual Story<Input> + .bind({}) |
buildExtensionTemplate(Template, Code, args?) |
| Code source | tagToString() or ?raw import |
Always ?raw import via buildExtensionTemplate |
| Meta typing | Untyped export default { } |
export default { } satisfies Meta<Input> |
| Input type import | From ./component-browser |
From ./index.marko |
| Event argTypes | Explicit action: "on-change" entries |
Omitted -- native events documented via pass-through note |
| Story boilerplate | ~10--15 lines per story | 1--3 lines per story |
Template
import { buildExtensionTemplate } from "../../common/storybook/utils";
import { type Meta } from "@storybook/marko";
import Readme from "./README.md";
import Component, { type Input } from "./index.marko";
import DefaultTemplate from "./examples/default.marko";
import DefaultCode from "./examples/default.marko?raw";
export default {
title: "category/evo-{name}",
component: Component,
parameters: {
docs: {
description: { component: Readme },
},
},
argTypes: {
// Custom props with controls
myProp: {
type: "string",
control: "text",
description: "Description of prop",
},
// Attribute tags with attributes
myAttrTag: {
description: "Attr tag",
"@": {
/* nested argTypes */
}
}
// controllable two-way bound props
value: {
controllable: true,
type: "string",
control: "text",
description: "The current value",
},
// Pass-through HTML attributes note
["<element> attributes" as any]: {
description: "All attributes and event handlers from [the native HTML `<element>` tag](...) will be passed through",
},
},
} satisfies Meta<Input>;
export const Default = buildExtensionTemplate(DefaultTemplate, DefaultCode);
Example files
Each example .marko file should be a minimal self-contained usage:
// examples/default.marko
<evo-{name} ...input/>
// examples/with-label.marko
<span class="field">
<label class="field__label field__label--start" for="my-id">
Label
</label>
<evo-{name} ...input id="my-id"/>
</span>
Do not move all examples from Marko 5, start with default.marko and controllable.marko and only add new ones as necessary to be mostly comprehensive.
Test patterns
Browser tests -- test/test.browser.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup } from "@marko/testing-library";
import { composeStories } from "@storybook/marko";
import * as stories from "../{name}.stories";
const { Default } = composeStories(stories);
afterEach(cleanup);
let component: Awaited<ReturnType<typeof render>>;
describe("evo-{name}", () => {
beforeEach(async () => {
component = await render(Default);
});
it("should render correctly", () => {
expect(component.getByRole("...")).toBeTruthy();
});
});
Server tests -- test/test.server.ts
import { describe, it } from "vitest";
import { composeStories } from "@storybook/marko";
import { snapshotHTML } from "../../../common/test-utils/snapshots";
import * as stories from "../{name}.stories";
const { Default, Disabled } = composeStories(stories);
describe("evo-{name} SSR", () => {
it("renders default", async () => {
await snapshotHTML(Default);
});
it("renders disabled", async () => {
await snapshotHTML(Disabled);
});
});
Key differences from Marko 5 tests
- File extension:
.js->.ts - Remove
testPassThroughAttributescalls -- Marko 6 handles this natively - Replace
snapshotHTML(__dirname)factory with directsnapshotHTML(story)calls - Remove
component-browserimports and renderer patching @marko/testing-libraryAPI (render,fireEvent,cleanup,emitted) is unchanged
Prop audit -- align with Marko 5 and simplify
Before finalizing the Input interface, compare the Marko 5 props:
- Remove event wrapper props --
"on-change","on-select", etc. are replaced by native DOM events or two-way binding callbacks. - Remove framework workarounds --
toJSON,processHtmlAttributes,WithNormalizedPropsare not needed. - Convert kebab-case to camelCase --
"input-size"->inputSize,"floating-label"->floatingLabel. - Simplify boolean semantics -- convert negative booleans to positive where appropriate (see table above).
- Add
a11yTextprop if the Marko 5 version usedaria-labelora11y-*-text. - If uncertain about a prop, stop and ask before proceeding.
Checklist before finishing
- Single
index.markofile -- no separatecomponent.tsorcomponent-browser.ts - No
marko-tag.json-- all types inexport interface Input - No
processHtmlAttributes-- native spread with...htmlInput - No
WithNormalizedPropswrapper - No
Omit<..., 'on${string}'>-- Marko 6 handles events natively - No
this.emit()-- use two-way binding (:=) or native event pass-through - No
this.state/this.getEl()/component.getElId()-- use<let/>,<div/$ref>,<id/> - No
$ varscriptlets -- use<const/>and<let/> - No dynamic tags for polymorphic elements -- use
<define>with branch-specific attributes -
<if(...)>converted to<if=...> -
renderBodyreferences converted tocontent - MakeupJS libraries replaced with evo headless components where available
-
toJSONhacks removed - Props are camelCase (not kebab-case)
-
style.tscopied (identical content) - Icon tags renamed:
<ebay-*-icon>-><evo-icon-*> - Stories use
buildExtensionTemplate()+satisfies Meta<Input> - Tests use
.tsextension, notestPassThroughAttributes, directsnapshotHTML()calls - Examples in
examples/directory withdefault.marko -
npm run build -w packages/evo-markopasses