Summary
Redesign the plugin/theme architecture to cleanly separate three distinct concerns that are currently entangled through a single IPlugin interface.
Problem
Currently everything runs through IPlugin:
- Plugins (OneDrive, Serve, Compress) → use
ConfigureServices(), GetCommands()
- Themes (Lumina) → implement
IPlugin but use none of it, only provide files
- Extensions (Lumina.Statistics) → implement
IPlugin, only provide files + prefix
- Local themes → don't implement
IPlugin at all, are a special case everywhere
This causes:
- Themes have empty no-op
ConfigureServices(), GetCommands()
plugin list must actively filter out themes (is not ITheme)
theme list must actively filter for themes (OfType<ITheme>())
- Local themes live outside the system — created on-demand, not in DI, need type checks
IThemeExtension : IPlugin even though extensions have nothing to do with plugins
- Package type (NuGet "RevelaTheme") doesn't match runtime type (
IPlugin)
- Same
PluginManager.InstallAsync() for both, with separate validation hacks
Design
Three Separate Contracts
interface IPackage { PackageMetadata Metadata { get; } }
interface IPlugin : IPackage
{
void ConfigureServices(IServiceCollection services);
void ConfigureConfiguration(IConfigurationBuilder config) { }
IEnumerable<CommandDescriptor> GetCommands(IServiceProvider sp) => [];
}
interface IThemeProvider : IPackage
{
string? Prefix { get; } // null = base theme, "statistics" = extension
string? TargetTheme { get; } // null = standalone, "Lumina" = extends Lumina
ThemeManifest Manifest { get; }
Stream? GetFile(string path);
IEnumerable<string> GetAllFiles();
}
No IThemeExtension — extensions are IThemeProvider with a Prefix.
PackageLoader: One Scan, Separate Lists
sealed class PackageLoader
{
IReadOnlyList<IPlugin> Plugins { get; }
IReadOnlyList<IThemeProvider> Themes { get; } // includes extensions
}
No filtering needed. Plugins are plugins. Themes are themes.
ThemeRegistry: Central Theme Management
sealed class ThemeRegistry
{
IThemeProvider? Resolve(string name, string projectPath); // Local > Installed
IReadOnlyList<IThemeProvider> GetExtensions(string themeName); // 0..N
IEnumerable<ThemeInfo> GetAvailable(string projectPath); // for theme list
}
ThemeFileResolver: Layered File Access
sealed class ThemeFileResolver
{
void Initialize(IThemeProvider theme, IReadOnlyList<IThemeProvider> extensions, string? localPath);
Stream? GetFile(string key); // priority: local > extension > theme, NO switch
IReadOnlyList<ResolvedFile> GetAllFiles(); // for theme files, theme extract
}
Replaces the SourceType switch in current TemplateResolver + AssetResolver.
Local Themes: First-Class Citizens
LocalThemeProvider : IThemeProvider — no special case, no type checks.
Source info via ThemeRegistry, not via is LocalThemeAdapter.
Theme Authors: Still One-Liners
public sealed class LuminaTheme()
: EmbeddedTheme(typeof(LuminaTheme).Assembly);
public sealed class LuminaStatisticsExtension()
: EmbeddedTheme(typeof(LuminaStatisticsExtension).Assembly);
Extensions = Distribution Packages for Template Snippets
- Installed like themes (
theme install Lumina.Statistics)
- Provide files under a prefix (
statistics/overview)
- Can be extracted to local → then DLL not needed at runtime
- Runtime layer stays for users who don't extract
- Use case: Install Lumina.Statistics, extract to local, use with custom theme
Runtime File Resolution
Layer Priority (highest → lowest):
1. Local folder: themes/{Name}/ ← user customizations
2. Extensions (0..N): installed DLLs ← prefix-scoped additions
3. Base theme (1): installed DLL or bundled ← foundation
Config (Proposed)
Option A — Unified dependencies:
{
"dependencies": {
"Spectara.Revela.Plugins.Serve": "1.0.0",
"Spectara.Revela.Themes.Lumina": "2.0.0",
"Spectara.Revela.Themes.Lumina.Statistics": "1.0.0"
}
}
Option B — Keep separate sections (current, but with extensions in themes):
{
"plugins": { ... },
"themes": { ... }
}
Migration Steps
- IPackage base interface — extract from IPlugin
- IThemeProvider interface — with Prefix/TargetTheme (replaces ITheme + IThemeExtension)
- PackageLoader — separate theme/plugin discovery
- ThemeRegistry — central theme resolution (replaces ThemeResolver)
- ThemeFileResolver — layered file access (replaces TemplateResolver/AssetResolver switches)
- LocalThemeProvider — implements IThemeProvider directly
- EmbeddedTheme base class — for NuGet themes and extensions
- Command updates — remove type checks, use ThemeRegistry
- Config migration — optional, can keep current structure
Context
Summary
Redesign the plugin/theme architecture to cleanly separate three distinct concerns that are currently entangled through a single
IPlugininterface.Problem
Currently everything runs through
IPlugin:ConfigureServices(),GetCommands()IPluginbut use none of it, only provide filesIPlugin, only provide files + prefixIPluginat all, are a special case everywhereThis causes:
ConfigureServices(),GetCommands()plugin listmust actively filter out themes (is not ITheme)theme listmust actively filter for themes (OfType<ITheme>())IThemeExtension : IPlugineven though extensions have nothing to do with pluginsIPlugin)PluginManager.InstallAsync()for both, with separate validation hacksDesign
Three Separate Contracts
No
IThemeExtension— extensions areIThemeProviderwith a Prefix.PackageLoader: One Scan, Separate Lists
No filtering needed. Plugins are plugins. Themes are themes.
ThemeRegistry: Central Theme Management
ThemeFileResolver: Layered File Access
Replaces the
SourceTypeswitch in current TemplateResolver + AssetResolver.Local Themes: First-Class Citizens
LocalThemeProvider : IThemeProvider— no special case, no type checks.Source info via
ThemeRegistry, not viais LocalThemeAdapter.Theme Authors: Still One-Liners
Extensions = Distribution Packages for Template Snippets
theme install Lumina.Statistics)statistics/overview)Runtime File Resolution
Config (Proposed)
Option A — Unified dependencies:
{ "dependencies": { "Spectara.Revela.Plugins.Serve": "1.0.0", "Spectara.Revela.Themes.Lumina": "2.0.0", "Spectara.Revela.Themes.Lumina.Statistics": "1.0.0" } }Option B — Keep separate sections (current, but with extensions in themes):
{ "plugins": { ... }, "themes": { ... } }Migration Steps
Context