Skip to content
Ray Fan edited this page Jun 3, 2019 · 3 revisions

Overview

A plugin adds a feature to your app that can be turned on or off by activating or deactivating the plugin in the Admin Panel. The plugin may have settings in which case they are updated also through the Admin Panel. A plugin communicates with the main app by events and plugins may or may not have any UI elements. 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.

A plugin is consisted of the following: Manifest file, Plugin class, UI, Settings and Event handlers, note the last 3 items UI, Settings and Event handlers are optional and not all plugins have them. Let's take a good at two plugins ForkMeRibbon and Shortcodes as examples.

Plugin Project

To create a plugin, first create a project under src\Plugins folder.

  • Inside Visual Studio, go to File menu, select New > Project.
  • Select ASP.NET Core Web Application.
  • Input project name, for example "ForkMeRibbonPlugin" > OK.
  • Verify ASP.NET Core 2.2 or later is selected.
  • Select Razor Class Library > OK.

Open the .csproj file, make sure the Sdk is Microsoft.NET.Sdk.Razor and TargetFramework is netcoreapp2.2, update them if they are different.

Then copy the following and paste it before the closing Project tag. This will copy necessary artifacts from the plugin project into the Fan.WebApp project at build time.

  <ItemGroup>
    <Content Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
    <Content Include="client\dist\**\*" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>

  <Target Name="CopyArtifacts" AfterTargets="Build">
    <PropertyGroup>
      <CopyToDir>..\..\Core\Fan.WebApp\Plugins\ForkMeRibbon</CopyToDir>
      <CopyToWwwrootDir>..\..\Core\Fan.WebApp\wwwroot\plugins\forkmeribbon\dist</CopyToWwwrootDir>
    </PropertyGroup>

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

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

Plugin Manifest File

Each plugin requires a manifest file called plugin.json to be at the root of the plugin project. The system loads the plugin based on this file, here is ForkMeRibbon'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-preview3",
  "pluginUrl": "https://github.com/FanrayMedia/Fanray/wiki/ForkMeRibbon-Plugin",
  "author": "Ray Fan",
  "authorUrl": "https://www.fanray.com"
}

Most of the properties are self-explanatory and they represent plugin information to be displayed inside the Admin Panel. The type property has the plugin's .NET class and assembly names, so this string is written in the format of "Namespace.PluginClass, Assembly". This property must be accurate, it's used by the system to instantiate the actual type of the plugin.

Plugin Class

A plugin is 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 DetailsUrl => "https://github.com/FanrayMedia/Fanray/wiki/ForkMeRibbon-Plugin";
    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.

Notice that the ForkMeRibbon plugin has a wrench icon whereas Shortcodes plugin not, that is because Shortcodes plugin currently does not have any settings, it is either on or off.

Plugin Settings

When a plugin does have settings as in this case for ForkMeRibbon plugin, a plugin settings page is needed. Create the Manage > Plugins folders and a Razor Page named ForkMeRibbonSettings.cshtml. One thing to note is that at runtime this page will seamlessly become part of the Fan.WebApp project's Manage folder, which is under authroization and only administrators can access.

Here is the ForkMeRibbonSettings.cshtml page in its entirety. The page contains a simple Vue.js component named ext-settings, you can name this whatever you like. The component contains other Vuetify.js form components for user to input the 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 data source. Here the code for 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. Create a Components folder and then create the following 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.

Go to the Clarity theme and 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.

Basically, the tag helpers loads the ViewComponents and the ViewComponents load the Razor Views.

The gh-fork-ribbon.min.css the ForkMeRibbon depends on is inside the client > dist folder, it is copied over to Fan.WebApp as part of the build.

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.

You can’t perform that action at this time.