On this page
Creating Scripts and Components¶
In MageObsidian Components, themes carry their own JavaScript and Vue components, under the theme directory:
app/design/frontend/Vendor/Theme/web/js # plain scripts (.ts / .js)
app/design/frontend/Vendor/Theme/web/components # Vue single-file components
Files under a module use view/frontend/web/ instead. Everything below works the same either way — the only difference is the namespace you refer to them by: Theme:: for the theme's own files, Vendor_Module:: for a module's.
Workflow¶
1. Scripts in web/js¶
A script is a module, not a bundle entry. It is loaded once, does its work and exports whatever the rest needs.
// app/design/frontend/Vendor/Theme/web/js/greeting.ts
export function greetUser(name: string): void {
console.log(`Hello, ${name}!`);
}
Load it from a template with the script() helper, which emits a <script type="module"> pointing at the built file:
{{ script('Theme::js/greeting') }}
Import it from other JS with the framework's specifier — never a relative path:
import { greetUser } from "Theme::js/greeting";
The specifier is what makes theme inheritance work: a child theme that ships web/js/greeting.ts transparently replaces the parent's for everyone who imports it. A relative path resolves to one specific file on disk and skips that entirely. A typo fails the build with suggestions rather than shipping broken.
2. Vue components in web/components¶
Components are single-file components using <script setup>. TypeScript is the default (lang="ts").
<!-- app/design/frontend/Vendor/Theme/web/components/CartSummary.vue -->
<script setup lang="ts">
import { computed } from "vue";
interface Item {
price: number;
}
const props = defineProps<{ items: Item[] }>();
const total = computed(() => props.items.reduce((sum, item) => sum + item.price, 0));
</script>
<template>
<div>
<h2>Total: {{ total }}</h2>
</div>
</template>
Mount it from a template as an island — a marker the runtime hydrates, not a page-wide app:
{{ render_vue('Theme::components/CartSummary', { items: items }) }}
render_vue(name, props, eager, serverHtml, hydrate):
| Argument | Meaning |
|---|---|
name |
Theme::components/X or Vendor_Module::components/X |
props |
Passed to the component as its props |
eager |
true mounts immediately; the default waits until the island scrolls into view |
serverHtml |
Markup for the component's initial state, so the island has something to show before Vue arrives |
hydrate |
With serverHtml, adopt that markup instead of replacing it — no layout shift |
Generate serverHtml with the mage-obsidian:island-ssr bin (see Vue islands). An eager island that replaces its container shifts the page on mount; bin/magento mage-obsidian:frontend:doctor reports every one that does.
Templates: Twig or phtml¶
With the optional Twig module installed, templates are .twig and the helpers above are Twig functions. They are declared is_safe => html, so they never need |raw.
Without it, the same capabilities are ViewModel calls from .phtml:
<?= $block->renderVueComponent('Theme::components/CartSummary', ['items' => $items]) ?>
<script type="module" src="<?= $escaper->escapeUrl($block->getViteFileUrl('Theme::js/greeting')) ?>"></script>
New themes are expected to be written in Twig; .phtml remains supported for compatibility and for the rare block whose PHP the theme has to keep.
Guidelines¶
- Location. Scripts in
web/js, components inweb/components. Under a module, the same two directories insideview/frontend/web/. - Naming. kebab-case for
web/js, PascalCase for components. - Imports. Always
Theme::/Vendor_Module::. Relative paths across a module or theme boundary break inheritance. - Composition API only.
<script setup>; the Options API is not used anywhere in the shipped storefront. - Dynamic imports work with the same specifiers, and are how an island's code stays out of the critical path:
const { greetUser } = await import("Theme::js/greeting"); - Vite processes both directories — bundled, tree-shaken, and fingerprinted into
web/generated.
Adding to an existing page¶
A component that has to appear inside a block someone else owns does not need that block edited. Declare a child block in layout XML and render it where the parent template calls child_html:
<referenceBlock name="product.info.main">
<block class="MageObsidian\ModernFrontend\Block\Template"
name="vendor.cart.summary" as="cart_summary"
template="Vendor_Module::cart/summary.twig"/>
</referenceBlock>
If the parent template has no hook for it, an interceptor can wrap the JS instead, without editing the module that owns it.