Overview
v0.12.0 implements ADR 0018: Eliminate Plugin Module State.
The core idea: @openelement/content and @openelement/i18n must not hold module-level state (_posts, _options). Instead, data flows through Vite virtual modules that read from LessBuildContext at load() time.
Breaking Changes
initBlogData() / getPosts() / getPostBySlug() — DELETED
These stateful APIs on @openelement/content are gone. No backward compatibility — v0.12.0 is a structured breaking change.
// Before (v0.11.x) — module state
import { getPosts, initBlogData } from '@openelement/content';
await initBlogData({ contentDir: 'posts' });
const posts = getPosts();
// After (v0.12.0) — virtual module
import { posts } from 'virtual:less-blog-data';
// posts is available during SSR, loaded by the virtual module
initI18nData() / getLocale() / getI18nOptions() — DELETED
Same pattern for i18n. Route components import from virtual:less-i18n-data instead.
buildCoreSubpathAliases() — DELETED
This function was the bridge for local Deno workspace resolution. It's replaced by @deno/vite-plugin@2 which resolves bare specifiers through Deno's native import.meta.resolve().
20 resolve.alias entries deleted from www/vite.config.ts.
EntryDescriptor.blog field — DELETED
The blog field on route entries was an artifact of the old stateful pattern. Removed entirely.
New Architecture: Virtual Data Modules
┌─ less() builds ctx ─────────────────────┐
│ ctx.plugins.blogOptions = { ... } │
│ ctx.plugins.i18nOptions = { ... } │
└──────────────────────────────────────────┘
│
lessContent().buildStart() │ lessI18n().buildStart()
writes to ctx │ writes to ctx
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ virtual:less- │ │ virtual:less- │
│ blog-data │ │ i18n-data │
│ load() reads ctx │ │ load() reads ctx │
└──────────────────┘ └──────────────────┘
│ │
└──────┬───────────────┘
▼
Route components import
from virtual modules
Key properties:
- Zero module state: all data in ctx, ctx in closure scope
- Single path: dev and build both use virtual module
load()— no dual init paths, noonSSRInit - HMR: content directory changes invalidate virtual module, browser reloads
- Static content pages:
lessContent({ pages: { contentDir, basePath? } })viavirtual:less-page-data
Data flows via ctx, not dynamic imports
The old architecture had two bridges:
- On init:
initBlogData()stored posts in module state - On SSR: route components imported through a second bridge (
onSSRInit)
v0.12.0 eliminates this: lessContent().buildStart() writes to ctx.plugins, and the virtual module's load() hook reads from ctx at call time. One bridge, zero sync risk.
Pure Functions
New pure functions replace all stateful patterns:
// @openelement/content
loadBlogData(dir) → BlogPost[]
loadPageData(opts) → ContentPage[]
// @openelement/i18n
loadI18nData(opts) → I18nConfig
These have no side effects, no module state, and are individually testable.
@deno/vite-plugin Integration
@deno/vite-plugin@2.0.2 handles local Deno workspace resolution:
import.meta.resolve('@openelement/core')→file:///path/to/packages/core/index.ts- Subpath support via
deno.jsonexports map - npm: passthrough → Vite handles natively through node_modules
This eliminates the recurring "Not a directory" prefix-matching bug (3 occurrences in project history).
Migration Guide
If you use @openelement/app (recommended)
No changes needed. lessjs() handles ctx passing automatically.
If you call initBlogData() / getPosts() directly
Replace with virtual module imports in your route components:
// Route component
import { getPostBySlug, posts } from 'virtual:less-blog-data';
If you call initI18nData() / getLocale() directly
// Route component
import { getDefaultLocale, locales } from 'virtual:less-i18n-data';
If you use lessContent() standalone
Pass ctx explicitly:
lessContent({ blog: {...}, ctx }); // ctx is now required
lessI18n({ locales: [...], ctx });
Release date: 2026-05-12