Skip to content

refactor: separate plugin and theme architecture with dedicated contracts #32

@kirkone

Description

@kirkone

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:

  1. Themes have empty no-op ConfigureServices(), GetCommands()
  2. plugin list must actively filter out themes (is not ITheme)
  3. theme list must actively filter for themes (OfType<ITheme>())
  4. Local themes live outside the system — created on-demand, not in DI, need type checks
  5. IThemeExtension : IPlugin even though extensions have nothing to do with plugins
  6. Package type (NuGet "RevelaTheme") doesn't match runtime type (IPlugin)
  7. 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

  1. IPackage base interface — extract from IPlugin
  2. IThemeProvider interface — with Prefix/TargetTheme (replaces ITheme + IThemeExtension)
  3. PackageLoader — separate theme/plugin discovery
  4. ThemeRegistry — central theme resolution (replaces ThemeResolver)
  5. ThemeFileResolver — layered file access (replaces TemplateResolver/AssetResolver switches)
  6. LocalThemeProvider — implements IThemeProvider directly
  7. EmbeddedTheme base class — for NuGet themes and extensions
  8. Command updates — remove type checks, use ThemeRegistry
  9. Config migration — optional, can keep current structure

Context

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions