Skip to content

CMS Content

MageObsidian treats CMS content as a first-class part of the storefront: a page can render from a versioned template, an author can drop a Vue island into it, and Tailwind classes written in the admin work β€” including after the theme was built.


A CMS page rendered from the theme

A page whose text belongs to the codebase β€” a privacy notice, a shipping policy β€” should ship and be translated with the theme, not live in a database row no install reproduces.

Magento already builds a layout handle per page, so the theme opts one in:

<!-- <theme>/Magento_Cms/layout/cms_page_view_id_<identifier>.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceBlock name="cms_page">
            <arguments>
                <argument name="obsidian_template" xsi:type="string">Magento_Cms::privacy/policy.twig</argument>
            </arguments>
        </referenceBlock>
    </body>
</page>

The template replaces the stored content, and nothing else changes: cms_page stays in the layout, so the document title, meta description, keywords, breadcrumbs and the cms-<identifier> body class still come from the CMS record. A merchant takes the page back by deleting the layout file.

Typography for authored content

A Tailwind preflight strips the browser defaults for h2, ul and blockquote, which is right for a designed storefront and wrong for HTML someone pasted into the admin. Every CMS page and block is therefore wrapped in .cms-content, and the theme styles that.

Those rules belong in the base layer:

1
2
3
4
@layer base {
  .cms-content :is(h1, h2, h3) { font-family: var(--font-display); }
  .cms-content h2 { font-size: var(--text-h3); }
}

Tailwind declares the order theme, base, components, utilities, so a class="text-3xl" written in the CMS wins over the prose rule no matter how specific that rule is. Unlayered, it would lose β€” the class would be in the stylesheet and still not apply.


Vue islands from content

Exposing one

Most components are not candidates: a product form needs a product in the registry, a cart counter belongs in the header. So a module says which of its own components make sense in content:

<type name="MageObsidian\ModernFrontend\Service\Cms\IslandRegistry">
    <arguments>
        <argument name="islands" xsi:type="array">
            <item name="product_carousel" xsi:type="array">
                <item name="component" xsi:type="string">Vendor_Module::catalog/ProductCarousel</item>
                <item name="label" xsi:type="string" translate="true">Product carousel</item>
                <item name="description" xsi:type="string" translate="true">A row of products from a category.</item>
                <item name="props" xsi:type="array">
                    <item name="limit" xsi:type="number">4</item>
                </item>
            </item>
        </argument>
    </arguments>
</type>

The array merges across modules, so anyone contributes without touching the engine. props are the defaults an author's own values are merged over.

Placing one

From the admin, Insert Widget β†’ Vue Island. The component dropdown is the registry intersected with the theme's Vite manifest β€” allowed and actually built, so it is impossible to pick a component whose chunk would 404.

By hand, the {{island}} directive:

1
2
3
{{island "Vendor_Module::catalog/ProductCarousel" strategy="eager"}}

{{island component="Vendor_Module::catalog/ProductCarousel"}}{"limit": 8}{{/island}}

Props go in the body, not in a parameter: the directive tokenizer reads key="value" pairs, so a JSON object's own quotes would end the value halfway through.

A component that is not registered renders nothing and says why in the log. strategy is visible (mount on scroll) or eager; see Vue Islands.

Islands need the storefront runtime

The directive is available wherever Magento's CMS filter runs, emails included. There is no island bootstrap in an email, so the marker would sit there inert.


Tailwind written in the CMS

Tailwind's scanner reads files. CMS content lives in a database, so a class written in the admin is invisible to a build. MageObsidian closes that in two steps.

At build time

bin/magento mage-obsidian:cms:export

This writes every page and block to var/mage-obsidian/cms/, and the engine emits a @source for that directory. Run it before building the theme and the build covers every class the content already uses, exactly β€” no list to curate.

The export also leaves the class list it saw, which the build copies into its own output as cms-candidates.json. That is the baseline for what comes next.

A theme opts out in theme.config.js:

1
2
3
export default {
    scanCmsContent: false,
};

After the build

An author who writes a new class the next day cannot wait for a deploy. So the storefront compiles the difference:

delta = classes(CMS content now) βˆ’ classes(the build's baseline)

It is derived, never appended to. There is no growing file to prune and no reset to remember: after a build, the difference is empty on its own and the stylesheet disappears from the page.

The delta is compiled by Tailwind's standalone binary β€” a single file, no Node:

1
2
3
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
chmod +x tailwindcss-linux-x64
mv tailwindcss-linux-x64 bin/tailwindcss

Use -musl on Alpine, and -arm64 on ARM. A different location is configurable at mage_obsidian/cms/tailwind_bin.

Because it is the real compiler, arbitrary values work: p-[13px] and text-[17px] compile as readily as p-4.

It runs when an author saves, and a quarter-hourly cron catches content that arrived some other way β€” an import, the REST API, a data patch. bin/magento mage-obsidian:cms:jit forces it, and --show reports the state without rebuilding.

Without the binary

Nothing breaks: saving works, the storefront serves what the build produced, and the classes written since simply do not apply. mage-obsidian:frontend:doctor reports it, and names the classes it could not generate.

Excluding content

Content pasted from somewhere else β€” a third-party embed β€” carries hundreds of foreign class names that would pollute both the build and the delta. Turn on Exclude from the CSS scan on the page or block, and it is skipped by both. Whatever styling it needs then has to come with it.


How it is served

The delta is a stylesheet under pub/media, but it is served through a controller at a fixed URL:

/mage-obsidian/cms/css

Magento's stock nginx config sends expires +1y for any .css under /media/, so a stable media URL could not be invalidated for a year β€” and a versioned one would put a changing href in the head of every page, invalidating the full page cache every time an author wrote a new class.

Here the href never changes and the ETag does the work: a client revalidates within five minutes and gets the new bytes the moment the content changes, while the page cache is never touched. The <link> is emitted only while there is a delta, so the empty↔non-empty flip is the one thing that changes a page's HTML.


Key Notes

  • The generated rules are wrapped in @layer utilities, joining the layer the built stylesheet already declares β€” a class compiled on the fly behaves exactly like one that came out of the build.
  • One delta per theme. The same class compiles to different CSS against different tokens.
  • Only classes written in a class attribute are seen. One assembled by JavaScript at runtime is invisible to any scanner, here or in the build.
  • Merchant-authored content is not run through Twig β€” that would be arbitrary execution from the admin. Templates come in through {{block ... template="....twig"}} or the layout argument above.

Next Steps

  • Vue Islands β€” how the components placed here are mounted.
  • Vite Build β€” where the @source for exported content fits in the pipeline.