Plugin

Overview

A plugin adds a feature to your app. Develop a plugin when the feature is optional to the core app. Plugins help the core stay focused on essential functionality and not to become bloated.

Plugins can be turned on or off and have settings that can be set by end users. Plugins used by the system cannot be turned off. Plugins communicate with the main app through events and they may or may not have any UI elements.

A typical plugin has the following,

  • Plugin manifest file plugin.json
  • Plugin class that derives from Plugin.cs which contains the plugin’s settings and UI related information
  • Plugin UI (optional)
    • Client artifacts such as CSS, images, JavaScript, SCSS etc. You can use whatever client-side technologies you want
    • The wwwroot folder that contains the output of the client artifacts
    • Server side components which outputs the actual view
  • Plugin settings (optional) a Razor Page used by the Admin Panel for users to configure the plugin’s settings
  • Event handlers (optional) that handle events fired by the main app

Plugin Flow

Plugins are placed in the solution’s Plugins folder while System Plugins are placed in the SysPlugins folder. Here are examples of ForkMeRibbon plugin and System Plugin Editor.md.

Plugin Projects

Plugin Project

A plugin project is a Razor Class Library, the .csproj file looks like below. When you build the project it will copy the plugin manifest plugin.json to the webapp project Fan.WebApp.

<Project Sdk="Microsoft.NET.Sdk.Razor">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <AddRazorSupportForMvc>true</AddRazorSupportForMvc>
  </PropertyGroup>

  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\Core\Fan\Fan.csproj" />
  </ItemGroup>

  <ItemGroup>
    <EmbeddedResource Include="wwwroot\**\*" />
  </ItemGroup>

  <Target Name="CopyArtifacts" AfterTargets="Build">
    <PropertyGroup>
      <CopyToDir>..\..\Core\Fan.WebApp\Plugins\ForkMeRibbon</CopyToDir>
    </PropertyGroup>

    <ItemGroup>
      <ManifestToCopy Include="$(OutputPath)\plugin.json" />
    </ItemGroup>

    <RemoveDir Directories="$(CopyToDir)" />
    <MakeDir Directories="$(CopyToDir)" Condition="!Exists('$(CopyToDir)')" />
    <Copy SourceFiles="@(ManifestToCopy)" DestinationFiles="$(CopyToDir)\%(FileName)%(Extension)" />
  </Target>
</Project>

Plugin Manifest File

Each plugin requires a manifest file named plugin.json to be at the root of the plugin project. The system loads the plugin based on this file, here is ForkMeRibbon plugin’s manifest file.

{
  "name": "ForkMeRibbon",
  "description": "Displays a Fork Me on GitHub ribbon at a corner of your website.",
  "type": "ForkMeRibbon.ForkMeRibbonPlugin, ForkMeRibbon",
  "version": "1.0.0",
  "requiresAtLeast": "1.1.0",
  "pluginUrl": "https://github.com/FanrayMedia/Fanray/wiki/ForkMeRibbon-Plugin",
  "author": "Ray Fan",
  "authorUrl": "https://www.fanray.com"
}

The name and description properties represent plugin information to be displayed in the Admin Panel > Plugins. The type property has the plugin’s .NET class and assembly names, this string is written in the format of “Namespace.PluginClass, Assembly”.

Plugin Class

Every plugin needs to have a class that derives from the Plugin base class.

/// <summary>
/// Plugin base class.
/// </summary>
public class Plugin : Extension
{
    /// <summary>
    /// Plugin meta id.
    /// </summary>
    [JsonIgnore]
    public int Id { get; set; }

    /// <summary>
    /// Returns true if plugin is active.
    /// </summary>
    public bool Active { get; set; }

    /// <summary>
    /// Return plugin's foot content view name, default is null.
    /// </summary>
    /// <returns></returns>
    public virtual string GetFootContentViewName() => null;

    /// <summary>
    /// Returns plugin's foot script view name, default is null.
    /// </summary>
    /// <returns></returns>
    public virtual string GetFootScriptsViewName() => null;

    /// <summary>
    /// Returns plugin's styles view name, default is null.
    /// </summary>
    /// <returns></returns>
    public virtual string GetStylesViewName() => null;

    /// <summary>
    /// Plugin's Configure startup method.
    /// </summary>
    /// <param name="app"></param>
    /// <param name="env"></param>
    public virtual void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
    }

    /// <summary>
    /// Plugin's ConfigureService startup method.
    /// </summary>
    /// <param name="services"></param>
    public virtual void ConfigureServices(IServiceCollection services)
    {
    }
}

The base class provides common properties Id and Active shared by all plugins. Plugins are persisted as JSON and saved in the Core_Meta table, the Id property is the id of a row in this table. Plugins can be either active or inactive, Active property represents this state. Your derived plugin class could more have properties as your plugin’s settings if applicable.

The base also provides methods you can override. These methods are there for either initialization purpose or for injecting html, js or css elements into the view.

The Configure and ConfigureServices methods are used if your plugin depends on other servies that you need to add during startup as well as wiring up any events to event handlers. The ShortcodesPlugin is a good example which adds the ShortcodeService and wiring up ModelPreRender<T> events to process the post content before it displays to users.

The GetFootContentViewName, GetFootScriptsViewName and GetStylesViewName methods are used to inject html elments, js scripts and css styles into the theme view layout.

The actual UI elements are Razor views (.cshtml) that live inside plugin’s Components folder with a simple ViewComponent class that returns the view.

Here is the ForkMeRibbonPlugin class.

public class ForkMeRibbonPlugin : Plugin
{
    [Required]
    public string Text { get; set; } = "Fork me on GitHub";
    [Required]
    public string Url { get; set; } = "https://github.com/FanrayMedia/Fanray";
    public ERibbonPosition Position { get; set; } = ERibbonPosition.RightBottom;
    public bool Sticky { get; set; } = true;

    public string GetPositionString()
    {
        var str = Position.ToString().ToLower();
        var idx = str.StartsWith("left") ? 4 : 5;
        return str.Insert(idx, "-");
    }

    public override string GetFootContentViewName() => "Ribbon";
    public override string GetStylesViewName() => "RibbonStyles";

    public override string SettingsUrl =>
        (Folder.IsNullOrEmpty()) ? "" : $"/{PluginService.PLUGIN_DIR}/{Folder}Settings?pluginId={Id}";
}

The Text, Url, Position and Sticky are settings for this plugin the user can set. Note the override SettingsUrl property, when its value returns a value other than blank or null, the Admin Panel will display the settings icons the user can invoke to update the plugin’s settings.

Plugin Settings

Plugin settings is a Razor Page placed inside the Manage > Plugins folder. It’s named with the name of the plugin followed by Settings, e.g. ForkMeRibbonSettings.cshtml. The settings are under authroization and accessible only by administrators.

Here is the ForkMeRibbonSettings.cshtml page in its entirety. The page contains a simple Vuejs component named ext-settings, you can name this whatever you like. The component contains a form and a Save button for user save plugin settings.

@page
@model ForkMeRibbon.Manage.Plugins.ForkMeRibbonSettingsModel
@{
    ViewData["Title"] = "ForkMeRibbon Plugin Settings";
    Layout = "_SettingsLayout";
}

<ext-settings inline-template>
    <v-form v-model="valid">
        <v-text-field label="Dispaly Text"
                      v-model="ext.text"
                      v-on:keydown.enter.prevent="save"
                      required
                      :rules="textRules"></v-text-field>
        <v-text-field label="Url"
                      v-model="ext.url"
                      v-on:keydown.enter.prevent="save"
                      required
                      :rules="textRules"></v-text-field>
        <v-checkbox label="Fixed" v-model="ext.fixed"></v-checkbox>
        <v-layout row wrap style="margin: 5px 0 -12px 0">
            <v-flex style="font-size:smaller">Display Position</v-flex>
        </v-layout>
        <v-layout row wrap style="margin-bottom: -8px">
            <v-flex>
                <v-radio-group v-model="selectedPos" row>
                    <v-radio v-for="(pos, index) in positions"
                             :key="index"
                             :label="pos"
                             :value="pos"></v-radio>
                </v-radio-group>
            </v-flex>
        </v-layout>
        <v-btn @@click="save" :disabled="!valid">Save</v-btn>
    </v-form>
</ext-settings>

@section Scripts {
    <script>
        Vue.component('ext-settings', {
            data: () => ({
                ext: @Html.Raw(Model.ExtJson),
                valid: false,
                selectedPos: '@Model.Position',
                positions: @Html.Raw(Model.PositionsJson),
                textRules: [
                    v => !!v.trim() || 'Dispaly text is required',
                ],
            }),
            methods: {
                save() {
                    this.ext.position = this.selectedPos;
                    axios.post('/plugins/ForkMeRibbonSettings', this.ext, this.$root.headers)
                        .then(resp => this.$root.onExtSettingsUpdated({ msg: resp.data }))
                        .catch(err => this.$root.onExtSettingsUpdateErr(err));
                }
            },
        });
    </script>
}

Upon clicking on the Save button, the plugin’s settings are persisted to the database, the code on the server when that happens is in ForkMeRibbonSettings.cshtml.cs.

namespace ForkMeRibbon.Manage.Plugins
{
    public class ForkMeRibbonSettingsModel : PageModel
    {
        protected readonly IPluginService pluginService;
        public ForkMeRibbonSettingsModel(IPluginService pluginService)
        {
            this.pluginService = pluginService;
        }

        public string ExtJson { get; set; }
        public string PositionsJson { get; set; }
        public ERibbonPosition Position { get; set; }

        public async Task OnGet(int pluginId)
        {
            var plugin = (ForkMeRibbonPlugin)await pluginService.GetExtensionAsync(pluginId);
            ExtJson = JsonConvert.SerializeObject(plugin);

            var positionList = new List<string>();
            foreach (var display in Enum.GetValues(typeof(ERibbonPosition)))
            {
                positionList.Add(display.ToString());
            }
            PositionsJson = JsonConvert.SerializeObject(positionList);
            Position = plugin.Position;
        }

        public async Task<IActionResult> OnPostAsync([FromBody]ForkMeRibbonPlugin plugin)
        {
            if (ModelState.IsValid)
            {
                await pluginService.UpdatePluginAsync(plugin);
                return new JsonResult("Plugin settings updated.");
            }

            return BadRequest("Failed to update plugin settings.");
        }
    }
}

Plugin UI

Not all plugins have UI elements, in this example ForkMeRibbon plugin does. They are located in the Components folder with 2 Razor View files and 1 ViewComponent .cs file.

  • Ribbon.cshtml: the html output to inject to site layout page.
  • RibbonStyles.cshtml: styles to inject to layout’s head.
  • RibbonViewComponents.cs: contains ViewComponent classes that return the Razor views.

These views and component are used by Theme , for example go to Clarity theme project, inside the Views > Shared folder there is _Layout.cshtml and inside this file you will find the following tag helpers.

  • <plugin-area id="Styles" />: plugin styles will be injected here.
  • <plugin-area id="FootContent" />: plugin html will be injected here.
  • <plugin-area id="FootScripts" />: plugin js script will be injected here.

The tag helpers loads the ViewComponents and the ViewComponents load the Razor Views.

Plugin used by Theme

The gh-fork-ribbon.min.css the ForkMeRibbon depends on is inside the wwwroot folder.

Events

The goal of a plugin is to be non-intrusive, it interacts with the hosting app through events exposed by the host rather than the host calling on the plugin’s types.

To demostrate events, let’s take a look at the Shortcodes plugin. This plugin looks through a post’s html for special codes and interprets those into more complex html elements, such as displaying source code etc.

Right before the blog renders a post or list of posts it raises a ModelPreRender<T> event. The Shortcodes plugin handles these events and massages the html before the post is finally rendered out to the client.

Plugin Events

Theme
Widget