Open navigation

Blog/release

v0.12.0 — Virtual Data Modules + Zero Module State

Eliminated all module-level state from @openelement/content and @openelement/i18n. Pure functions replace stateful init/getter patterns. Virtual modules become the only data bridge for SSR routes.

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, no onSSRInit
  • HMR: content directory changes invalidate virtual module, browser reloads
  • Static content pages: lessContent({ pages: { contentDir, basePath? } }) via virtual:less-page-data

Data flows via ctx, not dynamic imports

The old architecture had two bridges:

  1. On init: initBlogData() stored posts in module state
  2. 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.json exports 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

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