On this page
Port a Luma extension¶
Verified on Adobe Commerce 2.4.9
A Luma extension has three parts the new frontend does not read: a .phtml template, a RequireJS/KnockoutJS script and LESS. Porting it means replacing each with its counterpart and telling the framework the module takes part. This guide does it for a fictional module, Acme_Badge, that shows a dismissible "New arrival" badge on the home page.
The steps come from a real port: a store module that also needed the compatibility file (it ships static assets, and without the declaration they are not deployed under an Obsidian theme) and a plugin on a MageObsidian\Catalog ViewModel. Acme_Badge reproduces the same moves with code written for this page.
The Luma module¶
app/code/Acme/Badge/
├── registration.php
├── etc/module.xml
└── view/frontend/
├── layout/cms_index_index.xml
├── requirejs-config.js
├── templates/badge.phtml
└── web/
├── js/badge.js
└── css/source/_module.less
badge.phtml mounted a jQuery widget through data-mage-init:
<p class="acme-badge" data-mage-init='{"Acme_Badge/js/badge": {"label": "<?= $block->escapeHtmlAttr(__('New arrival')) ?>"}}'></p>
requirejs-config.js mapped the widget and badge.js defined it with define(['jquery'], …). None of that runs under MageObsidian Components: there is no RequireJS and no Knockout.
Step 1: declare the compatibility¶
A module that does not declare itself is ignored: its layout, blocks and frontend configuration never reach the page. Add etc/mage_obsidian_compatibility.xml:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:MageObsidian_ModernFrontend:etc/xsd/mage_obsidian_compatibility.xsd">
<features>
<compatibility>true</compatibility>
</features>
</config>
Add MageObsidian_ModernFrontend to the <sequence> of etc/module.xml. The file and its optional universal flag are covered in Make a module compatible. Do this even for a module with no JavaScript: it also decides whether the module's static assets are deployed.
Step 2: replace the .phtml¶
The layout keeps working, but point the block at a template your theme engine renders and use the framework block class, which provides the island helpers. With the Twig module installed:
<block class="MageObsidian\ModernFrontend\Block\Template" name="acme.badge"
template="Acme_Badge::badge.twig"/>
{{ render_vue('Acme_Badge::Badge', { label: __('New arrival'), dismissLabel: __('Dismiss') }) }}
Without Twig, keep a .phtml and call $block->renderVueComponent('Acme_Badge::Badge', $props); see Templates (.phtml). Props are plain data: pass already translated strings, not a data-mage-init JSON blob.
Step 3: move the script to a Vue island¶
The widget's behavior becomes a component in view/frontend/web/components/Badge.vue:
<script setup>
import { ref } from "vue";
defineProps({
label: { type: String, required: true },
dismissLabel: { type: String, required: true },
});
const visible = ref(true);
</script>
<template>
<p v-if="visible" class="acme-badge">
<span>{{ label }}</span>
<button type="button" :aria-label="dismissLabel" @click="visible = false">×</button>
</p>
</template>
Use an island when the script drives markup. When it is plain logic with no markup, write an ESM module under view/frontend/web/js/ instead and import it by Vendor_Module:: specifier; see JavaScript & imports. Never import across modules with a relative path: it bypasses theme inheritance.
Delete requirejs-config.js and the widget file. Knockout data-bind templates have no counterpart: rebuild them as component templates.
Plugins on a core block or ViewModel carry over only when the target exists in the new storefront. Re-point the <plugin> at the matching MageObsidian\* ViewModel, in etc/frontend/di.xml.
Step 4: styles with Tailwind¶
Replace the LESS with view/frontend/web/css/module.extend.css. Tailwind 4 is CSS-first, so utilities are applied with @apply:
.acme-badge {
@apply inline-flex items-center gap-2 rounded-full bg-amber-100 px-3 py-1 text-sm font-medium text-amber-900;
}
.acme-badge button {
@apply cursor-pointer leading-none;
}
The module's CSS is imported before the theme's, so a theme can still override your tokens. More in Configuration.
Step 5: generate the contract and build¶
bin/magento setup:upgrade
bin/magento mage-obsidian:frontend:config --generate
Then reload PHP-FPM. The contract is loaded with require, and when opcache.validate_timestamps is off (the usual production setting, and the default in the local stack) the web workers keep serving the copy they cached. The symptom is a theme or module that the CLI sees and the page does not: layout from MageObsidian_* modules goes missing. Restart the PHP service your web server talks to (zento compose restart php php-noxdebug in the local stack), then build and flush:
pnpm build:theme MageObsidian/default
bin/magento cache:flush
Run pnpm from the vite/ directory. The contract's layout is also kept in the layout cache, so the flush matters.
Step 6: check it¶
Fetch the page and look for the island marker:
curl -s https://your-store.test/ | grep -c 'data-mage-island'
On the verification run the home went from 9 to 10 islands, and the new marker pointed at generated/Acme_Badge/components/Badge.js, which answered 200. The compiled stylesheet held the .acme-badge rules.
Final checklist:
-
etc/mage_obsidian_compatibility.xmlexists andMageObsidian_ModernFrontendis in the module sequence. -
setup:upgraderan andmodule:statuslists the module as enabled. -
mage-obsidian:frontend:config --generateran, and PHP-FPM was reloaded afterwards. - The theme was rebuilt and
cache:flushran. - The page contains a
data-mage-islandmarker for your component and its JavaScript returns 200. - No
requirejs-config.js,data-mage-init,x-magento-initor.lessis left in the module.
If a step does not behave, the contract reference describes what --generate writes and what the build validates. Islands, their strategies and hydration are in Vue Islands.