diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c0890d7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,202 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+
+# Archive data (generated by app)
+[Ww]ebsite/[Aa]rchives/*/
+[Ww]ebsite/[Ss]cripts/
+[Ww]ebsite/[Dd]ebug/
+
+# User-specific files
+*.suo
+*.user
+*.sln.docstates
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+x64/
+build/
+bld/
+[Bb]in/
+[Oo]bj/
+*.nupkg
+
+# Roslyn cache directories
+*.ide/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+#NUNIT
+*.VisualState.xml
+TestResult.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+*_i.c
+*_p.c
+*_i.h
+*.bat
+*.ilk
+*.meta
+*.obj
+*.pch
+*.pdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opensdf
+*.sdf
+*.cachefile
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# JustCode is a .NET coding addin-in
+.JustCode
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+## TODO: Comment the next line if you want to checkin your
+## web deploy settings but do note that will include unencrypted
+## passwords
+#*.pubxml
+
+# NuGet Packages Directory
+packages/*
+## TODO: If the tool you use requires repositories.config
+## uncomment the next line
+#!packages/repositories.config
+
+# Enable "build/" folder in the NuGet Packages folder since
+# NuGet packages use it for MSBuild targets.
+# This line needs to be after the ignore of the build folder
+# (and the packages folder if the line above has been uncommented)
+!packages/build/
+
+# Windows Azure Build Output
+csx/
+*.build.csdef
+
+# Windows Store app package directory
+AppPackages/
+
+# Others
+sql/
+*.Cache
+ClientBin/
+[Ss]tyle[Cc]op.*
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+node_modules/
+bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+
+# SQL Server files
+*.mdf
+*.ldf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# LightSwitch generated files
+GeneratedArtifacts/
+_Pvt_Extensions/
+ModelManifest.xml
+.vs
+
+# Custom
+ConnectionStrings.config
\ No newline at end of file
diff --git a/GitVersion.yml b/GitVersion.yml
new file mode 100644
index 0000000..5c67bde
--- /dev/null
+++ b/GitVersion.yml
@@ -0,0 +1,5 @@
+assembly-versioning-scheme: MajorMinorPatch
+mode: ContinuousDelivery
+branches: {}
+ignore:
+ sha: []
diff --git a/OnTopic.Web.Mvc.Host/App_Start/RouteConfig.cs b/OnTopic.Web.Mvc.Host/App_Start/RouteConfig.cs
new file mode 100644
index 0000000..80ef83c
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/App_Start/RouteConfig.cs
@@ -0,0 +1,60 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project OnTopicSample OnTopic Site
+\=============================================================================================================================*/
+using System.Web.Mvc;
+using System.Web.Routing;
+
+namespace OnTopic.Web.Mvc.Host {
+
+ /*============================================================================================================================
+ | CLASS: ROUTE CONFIGURATION
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides default routing configuration for MVC.
+ ///
+ public static class RouteConfig {
+
+ /*==========================================================================================================================
+ | METHOD: REGISTER ROUTES
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provided a , registers all routes associated with the application.
+ ///
+ ///
+ /// The route collection for the server, typically passed from the class.
+ ///
+ public static void RegisterRoutes(RouteCollection routes) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Handle OnTopic redirects
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ routes.MapRoute(
+ name: "TopicRedirect",
+ url: "Topic/{topicId}",
+ defaults: new { controller = "Redirect", action = "Redirect" }
+ );
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Handle OnTopic Web namespace
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ routes.MapRoute(
+ name: "WebTopics",
+ url: "Web/{*path}",
+ defaults: new { controller = "Topic", action = "Index", id = UrlParameter.Optional, rootTopic = "Web" }
+ );
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Handle default route convention
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ routes.MapRoute(
+ name: "Default",
+ url: "{controller}/{action}/{id}",
+ defaults: new { controller = "Fallback", action = "Index", id = UrlParameter.Optional }
+ );
+
+ }
+
+ } //Class
+} //Namespace
diff --git a/OnTopic.Web.Mvc.Host/Controllers/ErrorController.cs b/OnTopic.Web.Mvc.Host/Controllers/ErrorController.cs
new file mode 100644
index 0000000..fb62ce9
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Controllers/ErrorController.cs
@@ -0,0 +1,20 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project OnTopicSample OnTopic Site
+\=============================================================================================================================*/
+using OnTopic.ViewModels;
+using OnTopic.Web.Mvc.Controllers;
+
+namespace OnTopic.Web.Mvc.Host.Controllers {
+
+ /*============================================================================================================================
+ | CLASS: ERROR CONTROLLER
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides access to the views associated with 400 and 500 error results.
+ ///
+ public class ErrorController : ErrorControllerBase {
+
+ } // Class
+} // Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/Controllers/LayoutController.cs b/OnTopic.Web.Mvc.Host/Controllers/LayoutController.cs
new file mode 100644
index 0000000..0a6419c
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Controllers/LayoutController.cs
@@ -0,0 +1,90 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project OnTopicSample OnTopic Site
+\=============================================================================================================================*/
+using System.Threading.Tasks;
+using System.Web.Mvc;
+using OnTopic.Repositories;
+using OnTopic.Web.Mvc.Controllers;
+using OnTopic.Web.Mvc.Models;
+using OnTopic.ViewModels;
+using OnTopic.Mapping.Hierarchical;
+
+namespace OnTopic.Web.Mvc.Host.Controllers {
+
+ /*============================================================================================================================
+ | CLASS: LAYOUT CONTROLLER
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides access to the default homepage for the site.
+ ///
+ public class LayoutController : LayoutControllerBase {
+
+ /*==========================================================================================================================
+ | PRIVATE FIELDS
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ private readonly ITopicRepository _topicRepository = null;
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Initializes a new instance of a Topic Controller with necessary dependencies.
+ ///
+ /// A topic controller for loading OnTopic views.
+ public LayoutController(
+ ITopicRoutingService topicRoutingService,
+ IHierarchicalTopicMappingService hierarchicalTopicMappingService,
+ ITopicRepository topicRepository
+ ) : base(
+ topicRoutingService,
+ hierarchicalTopicMappingService
+ ) {
+ _topicRepository = topicRepository;
+ }
+
+ /*==========================================================================================================================
+ | PAGE LEVEL NAVIGATION
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides page-level navigation for the current page.
+ ///
+ public async Task PageLevelNavigation() {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Establish variables
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var currentTopic = CurrentTopic;
+ var navigationRootTopic = currentTopic;
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Identify navigation root
+ >-------------------------------------------------------------------------------------------------------------------------
+ | The navigation root in the case of the page-level navigation any parent of content type "PageGroup".
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (navigationRootTopic != null) {
+ while (navigationRootTopic.Parent != null && !navigationRootTopic.ContentType.Equals("PageGroup")) {
+ navigationRootTopic = navigationRootTopic.Parent;
+ }
+ }
+
+ if (navigationRootTopic?.Parent == null) navigationRootTopic = null;
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Construct view model
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var navigationViewModel = new NavigationViewModel() {
+ NavigationRoot = await HierarchicalTopicMappingService.GetRootViewModelAsync(navigationRootTopic),
+ CurrentKey = CurrentTopic?.GetUniqueKey()
+ };
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Return the corresponding view
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ return PartialView(navigationViewModel);
+
+ }
+
+ } // Class
+} // Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/Global.asax b/OnTopic.Web.Mvc.Host/Global.asax
new file mode 100644
index 0000000..3570555
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Global.asax
@@ -0,0 +1 @@
+<%@ Application Codebehind="Global.asax.cs" Inherits="OnTopic.Web.Mvc.Host.Global" Language="C#" %>
diff --git a/OnTopic.Web.Mvc.Host/Global.asax.cs b/OnTopic.Web.Mvc.Host/Global.asax.cs
new file mode 100644
index 0000000..33b7f9a
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Global.asax.cs
@@ -0,0 +1,56 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project OnTopicSample OnTopic Site
+\=============================================================================================================================*/
+using System;
+using System.Web;
+using System.Web.Mvc;
+using System.Web.Routing;
+
+namespace OnTopic.Web.Mvc.Host {
+
+ /*============================================================================================================================
+ | CLASS: GLOBAL
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides default configuration for the application, including any special processing that needs to happen relative to
+ /// application events (such as or .
+ ///
+ public class Global: HttpApplication {
+
+ /*==========================================================================================================================
+ | METHOD: APPLICATION START (EVENT HANDLER)
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides initial configuration for the application, including registration of MVC routes via the
+ /// class.
+ ///
+ void Application_Start(object sender, EventArgs e) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Register controller factory
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ ControllerBuilder.Current.SetControllerFactory(
+ new SampleControllerFactory()
+ );
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Register view engine
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ ViewEngines.Engines.Insert(0, new TopicViewEngine());
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Register routes
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ RouteConfig.RegisterRoutes(RouteTable.Routes);
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Require HTTPS
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ GlobalFilters.Filters.Add(new RequireHttpsAttribute());
+
+ }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/OnTopic.Web.Mvc.Host.csproj b/OnTopic.Web.Mvc.Host/OnTopic.Web.Mvc.Host.csproj
new file mode 100644
index 0000000..b5d3886
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/OnTopic.Web.Mvc.Host.csproj
@@ -0,0 +1,242 @@
+
+
+
+
+ Debug
+ AnyCPU
+
+
+ 2.0
+ {ECA95F46-BE8F-4CD3-BF67-56A747E2C2F4}
+ {349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc}
+ Library
+ Properties
+ OnTopic.Web.Mvc.Host
+ OnTopic.Web.Mvc.Host
+ v4.8
+ true
+
+ 44392
+
+
+
+
+
+
+
+
+ true
+ full
+ false
+ bin\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ true
+ pdbonly
+ true
+ bin\
+ TRACE
+ prompt
+ 4
+
+
+
+ ..\packages\Microsoft.Configuration.ConfigurationBuilders.Base.1.0.1\lib\Net471\Microsoft.Configuration.ConfigurationBuilders.Base.dll
+
+
+ ..\packages\Microsoft.Configuration.ConfigurationBuilders.UserSecrets.1.0.2\lib\Net471\Microsoft.Configuration.ConfigurationBuilders.UserSecrets.dll
+
+
+
+ ..\packages\Microsoft.VisualStudio.Validation.15.5.31\lib\netstandard2.0\Microsoft.VisualStudio.Validation.dll
+
+
+ ..\packages\OnTopic.4.0.0\lib\netstandard2.0\OnTopic.dll
+
+
+ ..\packages\OnTopic.Data.Caching.4.0.0\lib\netstandard2.0\OnTopic.Data.Caching.dll
+
+
+ ..\packages\OnTopic.Data.Sql.4.0.0\lib\netstandard2.0\OnTopic.Data.Sql.dll
+
+
+ ..\packages\OnTopic.ViewModels.4.0.0\lib\netstandard2.0\OnTopic.ViewModels.dll
+
+
+ ..\packages\System.ComponentModel.Annotations.4.6.0\lib\net461\System.ComponentModel.Annotations.dll
+
+
+
+ ..\packages\System.Data.SqlClient.4.7.0\lib\net461\System.Data.SqlClient.dll
+
+
+ ..\packages\System.IO.4.3.0\lib\net462\System.IO.dll
+ True
+ True
+
+
+ ..\packages\System.Net.Http.4.3.4\lib\net46\System.Net.Http.dll
+ True
+ True
+
+
+ ..\packages\System.Runtime.4.3.0\lib\net462\System.Runtime.dll
+ True
+ True
+
+
+ ..\packages\System.Security.Cryptography.Algorithms.4.3.0\lib\net463\System.Security.Cryptography.Algorithms.dll
+ True
+ True
+
+
+ ..\packages\System.Security.Cryptography.Encoding.4.3.0\lib\net46\System.Security.Cryptography.Encoding.dll
+ True
+ True
+
+
+ ..\packages\System.Security.Cryptography.Primitives.4.3.0\lib\net46\System.Security.Cryptography.Primitives.dll
+ True
+ True
+
+
+ ..\packages\System.Security.Cryptography.X509Certificates.4.3.0\lib\net461\System.Security.Cryptography.X509Certificates.dll
+ True
+ True
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ..\packages\Microsoft.AspNet.Razor.3.2.7\lib\net45\System.Web.Razor.dll
+
+
+ ..\packages\Microsoft.AspNet.Webpages.3.2.7\lib\net45\System.Web.Webpages.dll
+
+
+ ..\packages\Microsoft.AspNet.Webpages.3.2.7\lib\net45\System.Web.Webpages.Deployment.dll
+
+
+ ..\packages\Microsoft.AspNet.Webpages.3.2.7\lib\net45\System.Web.Webpages.Razor.dll
+
+
+ ..\packages\Microsoft.AspNet.Webpages.3.2.7\lib\net45\System.Web.Helpers.dll
+
+
+ ..\packages\Microsoft.Web.Infrastructure.1.0.0.0\lib\net40\Microsoft.Web.Infrastructure.dll
+
+
+ ..\packages\Microsoft.AspNet.Mvc.5.2.7\lib\net45\System.Web.Mvc.dll
+
+
+ ..\packages\Microsoft.CodeDom.Providers.DotNetCompilerPlatform.2.0.1\lib\net45\Microsoft.CodeDom.Providers.DotNetCompilerPlatform.dll
+
+
+
+
+
+
+
+
+
+
+
+ Global.asax
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Web.config
+
+
+ Web.config
+
+
+
+
+
+
+
+
+ {3b3ce34d-b5e5-47ca-bfef-e6740650f378}
+ OnTopic.Web.Mvc
+
+
+
+ 10.0
+ $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)
+
+
+
+
+
+
+
+
+ True
+ True
+ 56282
+ /
+ https://localhost:44392/
+ False
+ False
+
+
+ False
+
+
+
+
+
+
+ This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.
+
+
+
+
+
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/Properties/AssemblyInfo.cs b/OnTopic.Web.Mvc.Host/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..d58d2f8
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Properties/AssemblyInfo.cs
@@ -0,0 +1,35 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+// General Information about an assembly is controlled through the following
+// set of attributes. Change these attribute values to modify the information
+// associated with an assembly.
+[assembly: AssemblyTitle("OnTopic.Web.Mvc.Host")]
+[assembly: AssemblyDescription("")]
+[assembly: AssemblyConfiguration("")]
+[assembly: AssemblyCompany("")]
+[assembly: AssemblyProduct("OnTopic.Web.Mvc.Host")]
+[assembly: AssemblyCopyright("Copyright © 2019")]
+[assembly: AssemblyTrademark("")]
+[assembly: AssemblyCulture("")]
+
+// Setting ComVisible to false makes the types in this assembly not visible
+// to COM components. If you need to access a type in this assembly from
+// COM, set the ComVisible attribute to true on that type.
+[assembly: ComVisible(false)]
+
+// The following GUID is for the ID of the typelib if this project is exposed to COM
+[assembly: Guid("eca95f46-be8f-4cd3-bf67-56a747e2c2f4")]
+
+// Version information for an assembly consists of the following four values:
+//
+// Major Version
+// Minor Version
+// Build Number
+// Revision
+//
+// You can specify all the values or you can default the Revision and Build Numbers
+// by using the '*' as shown below:
+[assembly: AssemblyVersion("1.0.0.0")]
+[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/OnTopic.Web.Mvc.Host/SampleControllerFactory.cs b/OnTopic.Web.Mvc.Host/SampleControllerFactory.cs
new file mode 100644
index 0000000..57321b8
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/SampleControllerFactory.cs
@@ -0,0 +1,123 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project OnTopicSample OnTopic Site
+\=============================================================================================================================*/
+using System;
+using System.Configuration;
+using System.Web.Mvc;
+using System.Web.Routing;
+using OnTopic.Data.Caching;
+using OnTopic.Data.Sql;
+using OnTopic.Mapping;
+using OnTopic.Mapping.Hierarchical;
+using OnTopic.Repositories;
+using OnTopic.ViewModels;
+using OnTopic.Web.Mvc.Controllers;
+using OnTopic.Web.Mvc.Host.Controllers;
+
+namespace OnTopic.Web.Mvc.Host {
+
+ /*============================================================================================================================
+ | CLASS: CONTROLLER FACTORY
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Responsible for creating instances of factories in response to web requests. Represents the Composition Root for
+ /// Dependency Injection.
+ ///
+ class SampleControllerFactory : DefaultControllerFactory {
+
+ /*==========================================================================================================================
+ | PRIVATE INSTANCES
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ private readonly ITypeLookupService _typeLookupService = null;
+ private readonly ITopicMappingService _topicMappingService = null;
+ private readonly ITopicRepository _topicRepository = null;
+ private readonly Topic _rootTopic = null;
+
+ private readonly IHierarchicalTopicMappingService _hierarchicalTopicMappingService = null;
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Establishes a new instance of the , including any shared dependencies to be used
+ /// across instances of controllers.
+ ///
+ public SampleControllerFactory() : base() {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | ESTABLISH DATABASE CONNECTION
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var connectionString = ConfigurationManager.ConnectionStrings["OnTopic"].ConnectionString;
+ var sqlTopicRepository = new SqlTopicRepository(connectionString);
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | SAVE STANDARD DEPENDENCIES
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ _topicRepository = new CachedTopicRepository(sqlTopicRepository);
+ _typeLookupService = new TopicViewModelLookupService();
+ _topicMappingService = new TopicMappingService(_topicRepository, _typeLookupService);
+ _rootTopic = _topicRepository.Load();
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | CONSTRUCT HIERARCHICAL TOPIC MAPPING SERVICE
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var service = new HierarchicalTopicMappingService(
+ _topicRepository,
+ _topicMappingService
+ );
+
+ _hierarchicalTopicMappingService = new CachedHierarchicalTopicMappingService(
+ service
+ );
+
+ }
+
+ /*==========================================================================================================================
+ | GET CONTROLLER INSTANCE
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Overrides the factory method for creating new instances of controllers.
+ ///
+ /// A concrete instance of an .
+ protected override IController GetControllerInstance(RequestContext requestContext, Type controllerType) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Register
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var mvcTopicRoutingService = new MvcTopicRoutingService(
+ _topicRepository,
+ requestContext.HttpContext.Request.Url,
+ requestContext.RouteData
+ );
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Resolve
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ switch (controllerType.Name) {
+
+ case nameof(RedirectController):
+ return new RedirectController(_topicRepository);
+
+ case nameof(SitemapController):
+ return new SitemapController(_topicRepository);
+
+ case nameof(ErrorController):
+ return new ErrorController();
+
+ case nameof(LayoutController):
+ return new LayoutController(mvcTopicRoutingService, _hierarchicalTopicMappingService, _topicRepository);
+
+ case nameof(TopicController):
+ return new TopicController(_topicRepository, mvcTopicRoutingService, _topicMappingService);
+
+ default:
+ return base.GetControllerInstance(requestContext, controllerType);
+
+ }
+
+ }
+
+ } //Class
+} //Namespace
diff --git a/OnTopic.Web.Mvc.Host/Views/ContentList/Accordion.cshtml b/OnTopic.Web.Mvc.Host/Views/ContentList/Accordion.cshtml
new file mode 100644
index 0000000..1031179
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/ContentList/Accordion.cshtml
@@ -0,0 +1,7 @@
+@Html.Partial("~/Views/ContentList/ContentList.cshtml")
+
+
diff --git a/OnTopic.Web.Mvc.Host/Views/ContentList/ContentList.cshtml b/OnTopic.Web.Mvc.Host/Views/ContentList/ContentList.cshtml
new file mode 100644
index 0000000..36e6cb7
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/ContentList/ContentList.cshtml
@@ -0,0 +1,30 @@
+@model ContentListTopicViewModel
+
+
+
+Collections
+
+Categories
+
+ @foreach (var category in Model.Categories) {
+ @category.Title
+ }
+
+
+Content Items
+@foreach (var contentItem in Model.ContentItems) {
+ @contentItem.Key
+
+ Title: @contentItem.Title
+ Category: @contentItem.Category
+ Learn More
+
+ Description: @contentItem.Description
+
+}
+
+
diff --git a/OnTopic.Web.Mvc.Host/Views/ContentList/IndexedList.cshtml b/OnTopic.Web.Mvc.Host/Views/ContentList/IndexedList.cshtml
new file mode 100644
index 0000000..31b611e
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/ContentList/IndexedList.cshtml
@@ -0,0 +1,7 @@
+@Html.PartialAsync("~/Views/ContentList/ContentList.cshtml")
+
+
diff --git a/OnTopic.Web.Mvc.Host/Views/ContentList/LinkedList.cshtml b/OnTopic.Web.Mvc.Host/Views/ContentList/LinkedList.cshtml
new file mode 100644
index 0000000..18b0d99
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/ContentList/LinkedList.cshtml
@@ -0,0 +1,7 @@
+@Html.Partial("~/Views/ContentList/ContentList.cshtml")
+
+
diff --git a/OnTopic.Web.Mvc.Host/Views/ContentTypes/Page.cshtml b/OnTopic.Web.Mvc.Host/Views/ContentTypes/Page.cshtml
new file mode 100644
index 0000000..0b5a306
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/ContentTypes/Page.cshtml
@@ -0,0 +1,8 @@
+@model PageTopicViewModel
+
+@Html.Partial("_PageAttributes")
+
+
diff --git a/OnTopic.Web.Mvc.Host/Views/ContentTypes/PageGroup.cshtml b/OnTopic.Web.Mvc.Host/Views/ContentTypes/PageGroup.cshtml
new file mode 100644
index 0000000..c16cb14
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/ContentTypes/PageGroup.cshtml
@@ -0,0 +1,6 @@
+@model PageGroupTopicViewModel
+
+
diff --git a/OnTopic.Web.Mvc.Host/Views/ContentTypes/Video.cshtml b/OnTopic.Web.Mvc.Host/Views/ContentTypes/Video.cshtml
new file mode 100644
index 0000000..375aaae
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/ContentTypes/Video.cshtml
@@ -0,0 +1,14 @@
+@model VideoTopicViewModel
+
+@Html.Partial("_PageAttributes")
+
+Attributes
+
+
+
diff --git a/OnTopic.Web.Mvc.Host/Views/Error/Error.cshtml b/OnTopic.Web.Mvc.Host/Views/Error/Error.cshtml
new file mode 100644
index 0000000..951dae1
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/Error/Error.cshtml
@@ -0,0 +1,9 @@
+@model PageTopicViewModel
+
+@{
+ Layout = "~/Views/Layout/_Layout.cshtml";
+ ViewBag.Title = "Unhandled Error";
+}
+
+Error
+Error Message
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/Views/Error/InternalServer.cshtml b/OnTopic.Web.Mvc.Host/Views/Error/InternalServer.cshtml
new file mode 100644
index 0000000..bce14dd
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/Error/InternalServer.cshtml
@@ -0,0 +1,9 @@
+@model PageTopicViewModel
+
+@{
+ Layout = "~/Views/Layout/_Layout.cshtml";
+ ViewBag.Title = "Internal Server Error";
+}
+
+Internal Server Error
+Error Message
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/Views/Error/NotFound.cshtml b/OnTopic.Web.Mvc.Host/Views/Error/NotFound.cshtml
new file mode 100644
index 0000000..064463d
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/Error/NotFound.cshtml
@@ -0,0 +1,9 @@
+@model PageTopicViewModel
+
+@{
+ Layout = "~/Views/Layout/_Layout.cshtml";
+ ViewBag.Title = "Page Not Found";
+}
+
+Page Not Found
+Error Message
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/Views/Layout/Menu.cshtml b/OnTopic.Web.Mvc.Host/Views/Layout/Menu.cshtml
new file mode 100644
index 0000000..52d649f
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/Layout/Menu.cshtml
@@ -0,0 +1,28 @@
+@model NavigationViewModel
+
+Menu
+
+
+ @foreach (var topic in Model.NavigationRoot.Children) {
+ @WriteMenu(topic);
+ }
+
+
+
+@helper WriteMenu(NavigationTopicViewModel topic, int indentLevel = 1) {
+
+
+ @(topic.ShortTitle?? topic.Title?? topic.Key)
+
+ @foreach (var childTopic in topic.Children) {
+ @WriteMenu(childTopic, indentLevel+1);
+ }
+
+
+
+}
+
+
diff --git a/OnTopic.Web.Mvc.Host/Views/Layout/PageLevelNavigation.cshtml b/OnTopic.Web.Mvc.Host/Views/Layout/PageLevelNavigation.cshtml
new file mode 100644
index 0000000..440430e
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/Layout/PageLevelNavigation.cshtml
@@ -0,0 +1,17 @@
+@model NavigationViewModel
+
+@if (Model.NavigationRoot != null) {
+
+ PageLevelNavigation
+
+
+}
+
+
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/Views/Layout/_Layout.cshtml b/OnTopic.Web.Mvc.Host/Views/Layout/_Layout.cshtml
new file mode 100644
index 0000000..c8ffd69
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/Layout/_Layout.cshtml
@@ -0,0 +1,42 @@
+@model PageTopicViewModel
+
+
+
+
+ @(Model.MetaTitle?? Model.Title?? Model.Key)
+
+
+ @RenderSection("Head", false)
+
+
+
+
+
+ @Html.Action("Menu", "Layout")
+
+
+
+
+
+
+
+
+
+
+
+ @(Model.ContentType.Equals("PageGroup")? "Overview" : Model.Title)
+ @Model.Subtitle
+
+ @Html.Partial("_TopicAttributes")
+ @RenderBody()
+
+
+
+
+
+ @RenderSection("Scripts", false)
+
+
+
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/Views/Shared/_PageAttributes.cshtml b/OnTopic.Web.Mvc.Host/Views/Shared/_PageAttributes.cshtml
new file mode 100644
index 0000000..6b6dac8
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/Shared/_PageAttributes.cshtml
@@ -0,0 +1,19 @@
+@model PageTopicViewModel
+
+Attributes
+
+ MetaDescription: @Model.MetaDescription
+ MetaKeywords: @Model.MetaKeywords
+ MetaTitle: @Model.MetaTitle
+ NoIndex? @Model.NoIndex
+ ShortTitle: @Model.ShortTitle
+ SubTitle: @Model.Subtitle
+
+
+Body
+@Html.Raw(Model.Body)
+
+
diff --git a/OnTopic.Web.Mvc.Host/Views/Shared/_TopicAttributes.cshtml b/OnTopic.Web.Mvc.Host/Views/Shared/_TopicAttributes.cshtml
new file mode 100644
index 0000000..304c602
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/Shared/_TopicAttributes.cshtml
@@ -0,0 +1,18 @@
+@using OnTopic.ViewModels
+@model TopicViewModel
+
+Topic
+
+ Id: @Model.Id
+ Key: @Model.Key
+ UniqueKey: @Model.UniqueKey
+ WebPath: @Model.WebPath
+ IsHidden? @Model.IsHidden
+ LastModified: @Model.LastModified
+ View: @Model.View
+
+
+
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/Views/_ViewStart.cshtml b/OnTopic.Web.Mvc.Host/Views/_ViewStart.cshtml
new file mode 100644
index 0000000..914e724
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/_ViewStart.cshtml
@@ -0,0 +1,3 @@
+@{
+ Layout = "~/Views/Layout/_Layout.cshtml";
+}
diff --git a/OnTopic.Web.Mvc.Host/Views/web.config b/OnTopic.Web.Mvc.Host/Views/web.config
new file mode 100644
index 0000000..1537e12
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Views/web.config
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/Web.Debug.config b/OnTopic.Web.Mvc.Host/Web.Debug.config
new file mode 100644
index 0000000..fae9cfe
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Web.Debug.config
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/Web.Release.config b/OnTopic.Web.Mvc.Host/Web.Release.config
new file mode 100644
index 0000000..9a057ec
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Web.Release.config
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/Web.config b/OnTopic.Web.Mvc.Host/Web.config
new file mode 100644
index 0000000..5c6a7df
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/Web.config
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Host/packages.config b/OnTopic.Web.Mvc.Host/packages.config
new file mode 100644
index 0000000..451ae66
--- /dev/null
+++ b/OnTopic.Web.Mvc.Host/packages.config
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Tests/Controllers/ErrorController.cs b/OnTopic.Web.Mvc.Tests/Controllers/ErrorController.cs
new file mode 100644
index 0000000..7cc0a33
--- /dev/null
+++ b/OnTopic.Web.Mvc.Tests/Controllers/ErrorController.cs
@@ -0,0 +1,20 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using OnTopic.ViewModels;
+using OnTopic.Web.Mvc.Controllers;
+
+namespace OnTopic.Web.Mvc.Tests {
+
+ /*============================================================================================================================
+ | CLASS: ERROR CONTROLLER
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Concrete implementation of class, suitable for test purposes.
+ ///
+ public class ErrorController : ErrorControllerBase {
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Tests/Controllers/LayoutController.cs b/OnTopic.Web.Mvc.Tests/Controllers/LayoutController.cs
new file mode 100644
index 0000000..0ece6d1
--- /dev/null
+++ b/OnTopic.Web.Mvc.Tests/Controllers/LayoutController.cs
@@ -0,0 +1,36 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using OnTopic.Mapping.Hierarchical;
+using OnTopic.ViewModels;
+using OnTopic.Web.Mvc.Controllers;
+
+namespace OnTopic.Web.Mvc.Tests {
+
+ /*============================================================================================================================
+ | CLASS: LAYOUT CONTROLLER
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Concrete implementation of class, suitable for test purposes.
+ ///
+ public class LayoutController : LayoutControllerBase {
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Initializes a new instance of a Topic Controller with necessary dependencies.
+ ///
+ /// A topic controller for loading OnTopic views.
+ public LayoutController(
+ ITopicRoutingService topicRoutingService,
+ IHierarchicalTopicMappingService navigationMappingService
+ ) : base(
+ topicRoutingService,
+ navigationMappingService
+ ) { }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Tests/OnTopic.Web.Mvc.Tests.csproj b/OnTopic.Web.Mvc.Tests/OnTopic.Web.Mvc.Tests.csproj
new file mode 100644
index 0000000..7dc36e2
--- /dev/null
+++ b/OnTopic.Web.Mvc.Tests/OnTopic.Web.Mvc.Tests.csproj
@@ -0,0 +1,56 @@
+
+
+ {2AC4B63B-16D3-4398-BAEB-E9EF3A7636A8}
+ net48
+ {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}
+ 15.0
+ $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)
+ $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages
+ False
+ UnitTest
+ True
+ False
+ CS1591,1701,1702,CA1707,CA1062,CS8602,CS8604;CA1303;IDE0059
+ 8.0
+ enable
+
+
+ Ignia OnTopic (MVC) Unit Tests
+ Ignia
+ Ignia OnTopic Library
+ Provides unit tests for the OnTopic MVC library.
+ ©2020 Ignia, LLC
+ bin\$(Configuration)\
+
+
+ full
+ bin\$(Configuration)\OnTopic.Web.Mvc.Tests.XML
+ latest
+
+
+ pdbonly
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ..\..\..\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.ComponentModel.DataAnnotations.dll
+
+
+
+
+
+
+
diff --git a/OnTopic.Web.Mvc.Tests/Properties/AssemblyInfo.cs b/OnTopic.Web.Mvc.Tests/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..41b9158
--- /dev/null
+++ b/OnTopic.Web.Mvc.Tests/Properties/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+using System.Runtime.InteropServices;
+
+[assembly: ComVisible(false)]
+[assembly: Guid("2ac4b63b-16d3-4398-baeb-e9ef3a7636a8")]
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Tests/TopicControllerTest.cs b/OnTopic.Web.Mvc.Tests/TopicControllerTest.cs
new file mode 100644
index 0000000..0c7e0bb
--- /dev/null
+++ b/OnTopic.Web.Mvc.Tests/TopicControllerTest.cs
@@ -0,0 +1,249 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Web.Mvc;
+using System.Web.Routing;
+using OnTopic.Data.Caching;
+using OnTopic.Mapping;
+using OnTopic.Mapping.Hierarchical;
+using OnTopic.Repositories;
+using OnTopic.TestDoubles;
+using OnTopic.ViewModels;
+using OnTopic.Web.Mvc.Controllers;
+using OnTopic.Web.Mvc.Models;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace OnTopic.Web.Mvc.Tests {
+
+ /*============================================================================================================================
+ | CLASS: TOPIC CONTROLLER TEST
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides unit tests for the , and other classes that are part of
+ /// the namespace.
+ ///
+ [TestClass]
+ public class TopicControllerTest {
+
+ /*==========================================================================================================================
+ | PRIVATE VARIABLES
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ readonly RouteData _routeData = new RouteData();
+ readonly ITopicRepository _topicRepository;
+ readonly Uri _uri;
+ readonly Topic _topic;
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Initializes a new instance of the with shared resources.
+ ///
+ ///
+ /// This uses the to provide data, and then to
+ /// manage the in-memory representation of the data. While this introduces some overhead to the tests, the latter is a
+ /// relatively lightweight façade to any , and prevents the need to duplicate logic for
+ /// crawling the object graph. In addition, it initializes a shared reference to use for the various
+ /// tests.
+ ///
+ public TopicControllerTest() {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Establish dependencies
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ _topicRepository = new CachedTopicRepository(new StubTopicRepository());
+ _uri = new Uri("http://localhost/Web/Web_0/Web_0_1/Web_0_1_1");
+ _topic = _topicRepository.Load("Root" + _uri.PathAndQuery.Replace("/", ":"))!;
+
+ }
+
+ /*==========================================================================================================================
+ | TEST: TOPIC
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Triggers the action.
+ ///
+ [TestMethod]
+ public async Task TopicController_IndexAsync() {
+
+ var topicRoutingService = new MvcTopicRoutingService(_topicRepository, _uri, _routeData);
+ var mappingService = new TopicMappingService(_topicRepository, new TopicViewModelLookupService());
+
+ var controller = new TopicController(_topicRepository, topicRoutingService, mappingService);
+ var result = await controller.IndexAsync(_topic.GetWebPath()).ConfigureAwait(false) as TopicViewResult;
+ var model = result.Model as PageTopicViewModel;
+
+ controller.Dispose();
+
+ Assert.IsNotNull(model);
+ Assert.AreEqual("Web_0_1_1", model.Title);
+
+ }
+
+ /*==========================================================================================================================
+ | TEST: ERROR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Triggers the action.
+ ///
+ [TestMethod]
+ public void ErrorController_Index() {
+
+ var controller = new ErrorController();
+ var result = controller.Index("ErrorPage") as ViewResult;
+ var model = result.Model as PageTopicViewModel;
+
+ controller.Dispose();
+
+ Assert.IsNotNull(model);
+ Assert.AreEqual("ErrorPage", model.Title);
+
+ }
+
+ /*==========================================================================================================================
+ | TEST: NOT FOUND ERROR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Triggers the action.
+ ///
+ [TestMethod]
+ public void ErrorController_NotFound() {
+
+ var controller = new ErrorController();
+ var result = controller.NotFound("NotFoundPage") as ViewResult;
+ var model = result.Model as PageTopicViewModel;
+
+ controller.Dispose();
+
+ Assert.IsNotNull(model);
+ Assert.AreEqual("NotFoundPage", model.Title);
+
+ }
+
+ /*==========================================================================================================================
+ | TEST: INTERNAL SERVER ERROR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Triggers the action.
+ ///
+ [TestMethod]
+ public void ErrorController_InternalServer() {
+
+ var controller = new ErrorController();
+ var result = controller.InternalServer("InternalServer") as ViewResult;
+ var model = result.Model as PageTopicViewModel;
+
+ controller.Dispose();
+
+ Assert.IsNotNull(model);
+ Assert.AreEqual("InternalServer", model.Title);
+
+ }
+
+ /*==========================================================================================================================
+ | TEST: FALLBACK
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Triggers the action.
+ ///
+ [TestMethod]
+ public void FallbackController_Index() {
+
+ var controller = new FallbackController();
+ var result = controller.Index() as HttpNotFoundResult;
+
+ controller.Dispose();
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(404, result.StatusCode);
+ Assert.AreEqual("No controller available to handle this request.", result.StatusDescription);
+
+ }
+
+ /*==========================================================================================================================
+ | TEST: REDIRECT
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Triggers the action.
+ ///
+ [TestMethod]
+ public void RedirectController_TopicRedirect() {
+
+ var controller = new RedirectController(_topicRepository);
+ var result = controller.Redirect(11110) as RedirectResult;
+
+ controller.Dispose();
+
+ Assert.IsNotNull(result);
+ Assert.IsTrue(result.Permanent);
+ Assert.AreEqual("/Web/Web_1/Web_1_1/Web_1_1_1/", result.Url);
+
+ }
+
+ /*==========================================================================================================================
+ | TEST: SITEMAP
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Triggers the index action of the action.
+ ///
+ ///
+ /// Because the method references the property,
+ /// which is not set during unit testing, this test is expected to throw an exception. This is not ideal. In the
+ /// future, this may be modified to instead use a mock for a more sophisticated test.
+ ///
+ [TestMethod]
+ [ExpectedException(typeof(NullReferenceException), AllowDerivedTypes=false)]
+ public void SitemapController_Index() {
+
+ var controller = new SitemapController(_topicRepository);
+ var result = controller.Index() as ViewResult;
+ var model = result.Model as TopicEntityViewModel;
+
+ controller.Dispose();
+
+ Assert.IsNotNull(model);
+ Assert.AreEqual(_topicRepository, model.TopicRepository);
+ Assert.AreEqual("Root", model.RootTopic.Key);
+ Assert.AreEqual("Root", model.Topic.Key);
+
+ }
+
+ /*==========================================================================================================================
+ | TEST: MENU
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Triggers the action.
+ ///
+ [TestMethod]
+ public async Task Menu() {
+
+ var topicRoutingService = new MvcTopicRoutingService(_topicRepository, _uri, _routeData);
+ var mappingService = new HierarchicalTopicMappingService(
+ _topicRepository,
+ new TopicMappingService(
+ _topicRepository,
+ new TopicViewModelLookupService()
+ )
+ );
+
+ var controller = new LayoutController(topicRoutingService, mappingService);
+ var result = await controller.Menu().ConfigureAwait(false) as PartialViewResult;
+ var model = result.Model as NavigationViewModel;
+
+ controller.Dispose();
+
+ Assert.IsNotNull(model);
+ Assert.AreEqual(_topic.GetUniqueKey(), model.CurrentKey);
+ Assert.AreEqual("Root:Web", model.NavigationRoot.UniqueKey);
+ Assert.AreEqual(3, model.NavigationRoot.Children.Count());
+ Assert.IsTrue(model.NavigationRoot.IsSelected(_topic.GetUniqueKey()));
+
+ }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Tests/TopicRoutingServiceTest.cs b/OnTopic.Web.Mvc.Tests/TopicRoutingServiceTest.cs
new file mode 100644
index 0000000..3ce37a5
--- /dev/null
+++ b/OnTopic.Web.Mvc.Tests/TopicRoutingServiceTest.cs
@@ -0,0 +1,119 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using System;
+using System.Web.Routing;
+using OnTopic.Data.Caching;
+using OnTopic.Repositories;
+using OnTopic.TestDoubles;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace OnTopic.Web.Mvc.Tests {
+
+ /*============================================================================================================================
+ | CLASS: TOPIC ROUTING SERVICE TEST
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides unit tests for the class.
+ ///
+ [TestClass]
+ public class TopicRoutingServiceTest {
+
+ /*==========================================================================================================================
+ | PRIVATE VARIABLES
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ readonly ITopicRepository _topicRepository;
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Initializes a new instance of the with shared resources.
+ ///
+ ///
+ /// This uses the to provide data, and then to
+ /// manage the in-memory representation of the data. While this introduces some overhead to the tests, the latter is a
+ /// relatively lightweight façade to any , and prevents the need to duplicate logic for
+ /// crawling the object graph.
+ ///
+ public TopicRoutingServiceTest() {
+ _topicRepository = new CachedTopicRepository(new StubTopicRepository());
+ }
+
+ /*==========================================================================================================================
+ | TEST: TOPIC (ROUTE)
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Establishes route data and ensures that a topic is correctly identified based on that route.
+ ///
+ [TestMethod]
+ public void TopicRoute() {
+
+ var routes = new RouteData();
+ var uri = new Uri("http://localhost/Topics/Web/Web_0/Web_0_1/Web_0_1_1");
+ var topic = _topicRepository.Load("Root:Web:Web_0:Web_0_1:Web_0_1_1");
+
+ routes.Values.Add("rootTopic", "Web");
+ routes.Values.Add("path", "Web_0/Web_0_1/Web_0_1_1");
+
+ var topicRoutingService = new MvcTopicRoutingService(_topicRepository, uri, routes);
+ var currentTopic = topicRoutingService.GetCurrentTopic();
+
+ Assert.IsNotNull(currentTopic);
+ Assert.ReferenceEquals(topic, currentTopic);
+ Assert.AreEqual("Web_0_1_1", currentTopic.Key);
+
+ }
+
+ /*==========================================================================================================================
+ | TEST: TOPIC (URI)
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Establishes a URI based on a path and ensures that a topic is correctly identified based on that URI.
+ ///
+ [TestMethod]
+ public void TopicUri() {
+
+ var routes = new RouteData();
+ var uri = new Uri("http://localhost/Web/Web_0/Web_0_1/Web_0_1_1");
+ var topic = _topicRepository.Load("Root:Web:Web_0:Web_0_1:Web_0_1_1");
+
+ var topicRoutingService = new MvcTopicRoutingService(_topicRepository, uri, routes);
+ var currentTopic = topicRoutingService.GetCurrentTopic();
+
+ Assert.IsNotNull(currentTopic);
+ Assert.ReferenceEquals(topic, currentTopic);
+ Assert.AreEqual("Web_0_1_1", currentTopic.Key);
+
+ }
+
+ /*==========================================================================================================================
+ | TEST: ROUTES
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Establishes route data and ensures that those routes are available after initializing a new instance of the
+ /// .
+ ///
+ [TestMethod]
+ public void Routes() {
+
+ var routes = new RouteData();
+ var uri = new Uri("http://localhost/Web/Web_0/Web_0_1/Web_0_1_1");
+
+ routes.Values.Add("rootTopic", "Web");
+ routes.Values.Add("path", "Web_0/Web_0_1/Web_0_1_1");
+
+ var topicRoutingService = new MvcTopicRoutingService(_topicRepository, uri, routes);
+ var currentTopic = topicRoutingService.GetCurrentTopic();
+
+ Assert.IsNotNull(currentTopic);
+ Assert.AreEqual("Web", routes.GetRequiredString("rootTopic"));
+ Assert.AreEqual("Web_0/Web_0_1/Web_0_1_1", routes.GetRequiredString("path"));
+ Assert.AreEqual("Page", routes.GetRequiredString("contenttype"));
+
+ }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.Tests/packages.config b/OnTopic.Web.Mvc.Tests/packages.config
new file mode 100644
index 0000000..28e3f78
--- /dev/null
+++ b/OnTopic.Web.Mvc.Tests/packages.config
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc.sln b/OnTopic.Web.Mvc.sln
new file mode 100644
index 0000000..ae69ed1
--- /dev/null
+++ b/OnTopic.Web.Mvc.sln
@@ -0,0 +1,41 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 16
+VisualStudioVersion = 16.0.29215.179
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OnTopic.Web.Mvc", "OnTopic.Web.Mvc\OnTopic.Web.Mvc.csproj", "{3B3CE34D-B5E5-47CA-BFEF-E6740650F378}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{0D5A35CE-F5A4-4DDF-8938-8B1116CDF58E}"
+ ProjectSection(SolutionItems) = preProject
+ .gitignore = .gitignore
+ GitVersion.yml = GitVersion.yml
+ README.md = README.md
+ EndProjectSection
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OnTopic.Web.Mvc.Tests", "OnTopic.Web.Mvc.Tests\OnTopic.Web.Mvc.Tests.csproj", "{2AC4B63B-16D3-4398-BAEB-E9EF3A7636A8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OnTopic.Web.Mvc.Host", "OnTopic.Web.Mvc.Host\OnTopic.Web.Mvc.Host.csproj", "{ECA95F46-BE8F-4CD3-BF67-56A747E2C2F4}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {3B3CE34D-B5E5-47CA-BFEF-E6740650F378}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3B3CE34D-B5E5-47CA-BFEF-E6740650F378}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3B3CE34D-B5E5-47CA-BFEF-E6740650F378}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3B3CE34D-B5E5-47CA-BFEF-E6740650F378}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2AC4B63B-16D3-4398-BAEB-E9EF3A7636A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2AC4B63B-16D3-4398-BAEB-E9EF3A7636A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2AC4B63B-16D3-4398-BAEB-E9EF3A7636A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {ECA95F46-BE8F-4CD3-BF67-56A747E2C2F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {ECA95F46-BE8F-4CD3-BF67-56A747E2C2F4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {ECA95F46-BE8F-4CD3-BF67-56A747E2C2F4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {9E9FBF9F-CE4C-4721-B099-58A561875E1B}
+ EndGlobalSection
+EndGlobal
diff --git a/OnTopic.Web.Mvc/Controllers/ErrorControllerBase{T}.cs b/OnTopic.Web.Mvc/Controllers/ErrorControllerBase{T}.cs
new file mode 100644
index 0000000..070c179
--- /dev/null
+++ b/OnTopic.Web.Mvc/Controllers/ErrorControllerBase{T}.cs
@@ -0,0 +1,148 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using System.Web.Mvc;
+using OnTopic.Models;
+
+namespace OnTopic.Web.Mvc.Controllers {
+
+ /*============================================================================================================================
+ | CLASS: ERROR CONTROLLER
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides access to the views associated with 400 and 500 error results.
+ ///
+ ///
+ ///
+ /// Implementers may wish to provide derived classes which return more specific error messages. This class provides a
+ /// generic implementation that should suit most requirements.
+ ///
+ ///
+ /// In order to remain view model agnostic, the does not assume that a particular view
+ /// model will be used, and instead accepts a generic argument for any view model that implements the interface . Since generic controllers cannot be effectively routed to, however, that means that
+ /// implementors must, at minimum, provide a local instance of which sets the generic
+ /// value to the desired view model. To help enforce this, while avoiding ambiguity, this class is marked as
+ /// abstract and suffixed with Base .
+ ///
+ ///
+ public abstract class ErrorControllerBase : Controller where T : IPageTopicViewModel, new() {
+
+ /*==========================================================================================================================
+ | GET: /Error
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides the default custom error page for the site.
+ ///
+ /// The site's default error view.
+ [HttpGet]
+ public virtual ActionResult Index(string title = "General Error") {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Instantiate view model
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var viewModel = CreateErrorViewModel("Error", title);
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Return the view
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ return View("Error", viewModel);
+
+ }
+
+ /*==========================================================================================================================
+ | GET: /Error/NotFound
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides the custom 404 error page for the site.
+ ///
+ /// The site's 404 (not found) error view.
+ [HttpGet]
+ public virtual ActionResult NotFound(string title = "Page Not Found") {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Return the proper status code
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (Response != null) {
+ Response.StatusCode = 404;
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Instantiate view model
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var viewModel = CreateErrorViewModel("NotFound", title);
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Return the view
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ return View("NotFound", viewModel);
+
+ }
+
+ /*==========================================================================================================================
+ | GET: /Error/InternalServer
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides the custom 500 error page for the site.
+ ///
+ /// The site's 500 (internal server) error view.
+ [HttpGet]
+ public virtual ActionResult InternalServer(string title = "Internal Server Error") {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Return the proper status code
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (Response != null) {
+ Response.StatusCode = 500;
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Instantiate model
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var viewModel = CreateErrorViewModel("InternalServer", title);
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Return the view
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ return View("InternalServer", viewModel);
+
+ }
+
+ /*==========================================================================================================================
+ | CREATE ERROR VIEW MODEL
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Establishes an empty view model, populates it with appropriate values based on the method parameters, and returns it
+ /// for use in one of the actions.
+ ///
+ ///
+ /// The key name to use for the 's Key and WebPath properties.
+ ///
+ /// The title of the error page.
+ /// A view model representing an error message.
+ public virtual T CreateErrorViewModel(string key, string title) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Instantiate model
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var viewModel = new T {
+ Key = key,
+ UniqueKey = "Error:" + key,
+ WebPath = "/Error/" + key,
+ ContentType = "Page",
+ Title = title,
+ MetaKeywords = "",
+ MetaDescription = ""
+ };
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Return the view
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ return viewModel;
+
+ }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc/Controllers/FallbackController.cs b/OnTopic.Web.Mvc/Controllers/FallbackController.cs
new file mode 100644
index 0000000..b8b3ff3
--- /dev/null
+++ b/OnTopic.Web.Mvc/Controllers/FallbackController.cs
@@ -0,0 +1,41 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using System.Web.Mvc;
+
+namespace OnTopic.Web.Mvc.Controllers {
+
+ /*============================================================================================================================
+ | CLASS: FALLBACK CONTROLLER
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides an empty fallback controller, which will be called if no other controller is identified. The primary purpose of
+ /// this controller is to throw a 404.
+ ///
+
+ public class FallbackController : Controller {
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Initializes a new instance of a DefaultController.
+ ///
+ /// A DefaultController.
+ public FallbackController() : base() {
+ }
+
+ /*==========================================================================================================================
+ | GET: INDEX
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides the default page for the site.
+ ///
+ /// The site's default view.
+ [HttpGet]
+ public virtual ActionResult Index() => HttpNotFound("No controller available to handle this request.");
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc/Controllers/LayoutControllerBase{T}.cs b/OnTopic.Web.Mvc/Controllers/LayoutControllerBase{T}.cs
new file mode 100644
index 0000000..586ac1e
--- /dev/null
+++ b/OnTopic.Web.Mvc/Controllers/LayoutControllerBase{T}.cs
@@ -0,0 +1,129 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using System.Threading.Tasks;
+using System.Web.Mvc;
+using OnTopic.Mapping.Hierarchical;
+using OnTopic.Models;
+using OnTopic.Web.Mvc.Models;
+
+namespace OnTopic.Web.Mvc.Controllers {
+
+ /*============================================================================================================================
+ | CLASS: LAYOUT CONTROLLER
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides access to views for populating specific layout dependencies, such as the .
+ ///
+ ///
+ ///
+ /// As a best practice, global data required by the layout view are requested independently of the current page. This
+ /// allows each layout element to be provided with its own layout data, in the form of s, instead of needing to add this data to every view model returned by . The facilitates this by not only providing a default
+ /// implementation for , but additionally providing protected helper methods that aid in locating and
+ /// assembling and references that are relevant to
+ /// specific layout elements.
+ ///
+ ///
+ /// In order to remain view model agnostic, the does not assume that a particular view
+ /// model will be used, and instead accepts a generic argument for any view model that implements the interface . Since generic controllers cannot be effectively routed to, however, that means
+ /// implementors must, at minimum, provide a local instance of which sets the generic
+ /// value to the desired view model. To help enforce this, while avoiding ambiguity, this class is marked as
+ /// abstract and suffixed with Base .
+ ///
+ ///
+ public abstract class LayoutControllerBase : AsyncController where T : class, INavigationTopicViewModel, new() {
+
+ /*==========================================================================================================================
+ | PRIVATE VARIABLES
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ private readonly ITopicRoutingService _topicRoutingService = null;
+ private Topic _currentTopic = null;
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Initializes a new instance of a Topic Controller with necessary dependencies.
+ ///
+ /// A topic controller for loading OnTopic views.
+ protected LayoutControllerBase(
+ ITopicRoutingService topicRoutingService,
+ IHierarchicalTopicMappingService hierarchicalTopicMappingService
+ ) : base() {
+ _topicRoutingService = topicRoutingService;
+ HierarchicalTopicMappingService = hierarchicalTopicMappingService;
+ }
+
+ /*==========================================================================================================================
+ | CURRENT TOPIC
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides a reference to the current topic associated with the request.
+ ///
+ /// The Topic associated with the current request.
+ protected Topic CurrentTopic {
+ get {
+ if (_currentTopic == null) {
+ _currentTopic = _topicRoutingService.GetCurrentTopic();
+ }
+ return _currentTopic;
+ }
+ }
+
+ /*==========================================================================================================================
+ | HIERARCHICAL TOPIC MAPPING SERVICE
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides a reference to the associated with the request.
+ ///
+ /// The associated with the current request.
+ protected IHierarchicalTopicMappingService HierarchicalTopicMappingService { get; }
+
+ /*==========================================================================================================================
+ | MENU
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides the global menu for the site layout, which exposes the top two tiers of navigation.
+ ///
+ public async virtual Task Menu() {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Establish variables
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var currentTopic = CurrentTopic;
+ var navigationRootTopic = (Topic)null;
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Identify navigation root
+ >-------------------------------------------------------------------------------------------------------------------------
+ | The navigation root in the case of the main menu is the namespace; i.e., the first topic underneath the root.
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ navigationRootTopic = HierarchicalTopicMappingService.GetHierarchicalRoot(currentTopic, 2, "Web");
+ var navigationRoot = await HierarchicalTopicMappingService.GetRootViewModelAsync(
+ navigationRootTopic,
+ 3,
+ t => t.ContentType != "PageGroup"
+ ).ConfigureAwait(false);
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Construct view model
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var navigationViewModel = new NavigationViewModel() {
+ NavigationRoot = navigationRoot,
+ CurrentKey = CurrentTopic?.GetUniqueKey()
+ };
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Return the corresponding view
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ return PartialView(navigationViewModel);
+
+ }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc/Controllers/RedirectController.cs b/OnTopic.Web.Mvc/Controllers/RedirectController.cs
new file mode 100644
index 0000000..f303f58
--- /dev/null
+++ b/OnTopic.Web.Mvc/Controllers/RedirectController.cs
@@ -0,0 +1,75 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using System.Web.Mvc;
+using OnTopic.Repositories;
+
+namespace OnTopic.Web.Mvc.Controllers {
+
+ /*============================================================================================================================
+ | CLASS: REDIRECT CONTROLLER
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Handles redirect based on TopicID, thus allowing permanent redirects to be setup.
+ ///
+ ///
+ /// Typically, a page is requested based on the value, which is a hash of
+ /// its . When a is moved to a different location in the topic graph,
+ /// however, its will return a different value, corresponding to its new location. To allow
+ /// permanent references to page, therefore, the accepts paths based on the , which is expected to be stable for the lifetime of a entity.
+ ///
+ public class RedirectController : Controller {
+
+ /*==========================================================================================================================
+ | PRIVATE VARIABLES
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ private readonly ITopicRepository _topicRepository = null;
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Initializes a new instance of a Topic Controller with necessary dependencies.
+ ///
+ ///
+ /// An implementation of an to retrieve the current from.
+ ///
+ /// A topic controller for loading OnTopic views.
+ public RedirectController(ITopicRepository topicRepository) : base() {
+ _topicRepository = topicRepository;
+ }
+
+ /*==========================================================================================================================
+ | REDIRECT
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Redirect based on .
+ ///
+ /// The to lookup in the .
+ [HttpGet]
+ public virtual ActionResult Redirect(int topicId) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Find the topic with the correct PageID.
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var topic = _topicRepository.Load(topicId);
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Provide error handling
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (topic == null) {
+ return HttpNotFound("Invalid TopicID.");
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Perform redirect
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ return RedirectPermanent(topic.GetWebPath());
+
+ }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc/Controllers/SitemapController.cs b/OnTopic.Web.Mvc/Controllers/SitemapController.cs
new file mode 100644
index 0000000..69d78e2
--- /dev/null
+++ b/OnTopic.Web.Mvc/Controllers/SitemapController.cs
@@ -0,0 +1,65 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using System.Web.Mvc;
+using OnTopic.Repositories;
+using OnTopic.Web.Mvc.Models;
+
+namespace OnTopic.Web.Mvc.Controllers {
+
+ /*============================================================================================================================
+ | CLASS: SITEMAP CONTROLLER
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Responds to requests for a sitemap according to sitemap.org's schema. The view is expected to recursively loop over
+ /// child topics to generate the appropriate markup.
+ ///
+ public class SitemapController : Controller {
+
+ /*==========================================================================================================================
+ | PRIVATE VARIABLES
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ private readonly ITopicRepository _topicRepository = null;
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Initializes a new instance of a Topic Controller with necessary dependencies.
+ ///
+ /// A topic controller for loading OnTopic views.
+ public SitemapController(ITopicRepository topicRepository) {
+ _topicRepository = topicRepository;
+ }
+
+ /*==========================================================================================================================
+ | GET: /SITEMAP
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides the Sitemap.org sitemap for the site.
+ ///
+ /// The site's homepage view.
+ [HttpGet]
+ public virtual ActionResult Index() {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Establish Page Topic
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var topicViewModel = new TopicEntityViewModel(_topicRepository, _topicRepository.Load());
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | DEFINE CONTENT TYPE
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ Response.ContentType = "text/xml";
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Return the homepage view
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ return View("Sitemap", topicViewModel);
+
+ }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc/Controllers/TopicController.cs b/OnTopic.Web.Mvc/Controllers/TopicController.cs
new file mode 100644
index 0000000..0fe176d
--- /dev/null
+++ b/OnTopic.Web.Mvc/Controllers/TopicController.cs
@@ -0,0 +1,190 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using System;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+using System.Web.Mvc;
+using OnTopic.Internal.Diagnostics;
+using OnTopic.Mapping;
+using OnTopic.Repositories;
+
+namespace OnTopic.Web.Mvc.Controllers {
+
+ /*============================================================================================================================
+ | CLASS: TOPIC TEST
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides a default ASP.NET MVC Controller for any paths associated with the Ignia Topic Library. Responsible for
+ /// identifying the topic associated with the given path, determining its content type, and returning a view associated with
+ /// that content type (with potential overrides for multiple views).
+ ///
+ public class TopicController : AsyncController {
+
+ /*==========================================================================================================================
+ | PRIVATE VARIABLES
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ private readonly ITopicRoutingService _topicRoutingService = null;
+ private readonly ITopicMappingService _topicMappingService = null;
+ private Topic _currentTopic = null;
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Initializes a new instance of a Topic Controller with necessary dependencies.
+ ///
+ /// A topic controller for loading OnTopic views.
+ public TopicController(
+ ITopicRepository topicRepository,
+ ITopicRoutingService topicRoutingService,
+ ITopicMappingService topicMappingService
+ ) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Validate input
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ Contract.Requires(topicRepository, "A concrete implementation of an ITopicRepository is required.");
+ Contract.Requires(topicRoutingService, "A concrete implementation of an ITopicRoutingService is required.");
+ Contract.Requires(topicMappingService!= null, "A concrete implementation of an ITopicMappingService is required.");
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Set values locally
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ TopicRepository = topicRepository;
+ _topicRoutingService = topicRoutingService;
+ _topicMappingService = topicMappingService;
+
+ }
+
+ /*==========================================================================================================================
+ | TOPIC REPOSITORY
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides a reference to the Topic Repository in order to gain arbitrary access to the entire topic graph.
+ ///
+ /// The TopicRepository associated with the controller.
+ protected ITopicRepository TopicRepository { get; }
+
+ /*==========================================================================================================================
+ | CURRENT TOPIC
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides a reference to the current topic associated with the request.
+ ///
+ /// The Topic associated with the current request.
+ protected Topic CurrentTopic {
+ get {
+ if (_currentTopic == null) {
+ _currentTopic = _topicRoutingService.GetCurrentTopic();
+ }
+ return _currentTopic;
+ }
+ }
+
+ /*==========================================================================================================================
+ | GET: INDEX (VIEW TOPIC)
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides access to a view associated with the current topic's Content Type, if appropriate, view (as defined by the
+ /// query string or topic's view.
+ ///
+ /// A view associated with the requested topic's Content Type and view.
+ public async virtual Task IndexAsync(string path) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Establish default view model
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var topicViewModel = await _topicMappingService.MapAsync(CurrentTopic).ConfigureAwait(false);
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Return topic view
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ return new TopicViewResult(topicViewModel, CurrentTopic.ContentType, CurrentTopic.View);
+
+ }
+
+ /*==========================================================================================================================
+ | EVENT: ON ACTION EXECUTING
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides universal validation of calls to any action on or its derivatives.
+ ///
+ ///
+ /// While the event can be used to provide a wide variety of
+ /// filters, this specific implementation is focused on validating the state of the . Namely,
+ /// it will provide error handling (if the is null), a redirect (if the 's Url attribute is set, and an unauthorized response (if the 's
+ /// flag is set.
+ ///
+ /// A view associated with the requested topic's Content Type and view.
+ [NonAction]
+ protected override void OnActionExecuting(ActionExecutingContext filterContext) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Validate parameters
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ Contract.Requires(filterContext, nameof(filterContext));
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Handle exceptions
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (CurrentTopic == null) {
+ filterContext.Result = HttpNotFound("There is no topic associated with this path.");
+ return;
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Handle disabled topic
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ //### TODO JJC082817: Should allow this to be bypassed for administrators; requires introduction of Role dependency
+ //### e.g., if (!Roles.IsUserInRole(Page?.User?.Identity?.Name ?? "", "Administrators")) {...}
+ if (CurrentTopic.IsDisabled) {
+ filterContext.Result = new HttpUnauthorizedResult("The topic at this location is disabled.");
+ return;
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Handle redirect
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (!String.IsNullOrEmpty(CurrentTopic.Attributes.GetValue("URL"))) {
+ filterContext.Result = RedirectPermanent(CurrentTopic.Attributes.GetValue("URL"));
+ return;
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Handle nested topics
+ >-----------------------------------------------------------------------------------------------------------------------—-
+ | Nested topics are not expected to be viewed directly; if a user requests a nested topic, return a 403 to indicate that
+ | the request is valid, but forbidden.
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (CurrentTopic.ContentType == "List" || CurrentTopic.Parent.ContentType == "List") {
+ filterContext.Result = new HttpStatusCodeResult(HttpStatusCode.Forbidden);
+ return;
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Handle page groups
+ >-----------------------------------------------------------------------------------------------------------------------—-
+ | PageGroups are a special content type for packaging multiple pages together. When a PageGroup is identified, the user is
+ | redirected to the first (non-hidden, non-disabled) page in the page group.
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (CurrentTopic.ContentType == "PageGroup") {
+ filterContext.Result = Redirect(
+ CurrentTopic.Children.Where(t => t.IsVisible()).FirstOrDefault().GetWebPath()
+ );
+ return;
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Base processing
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ base.OnActionExecuting(filterContext);
+
+ }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc/Models/NavigationViewModel{T}.cs b/OnTopic.Web.Mvc/Models/NavigationViewModel{T}.cs
new file mode 100644
index 0000000..d9ed701
--- /dev/null
+++ b/OnTopic.Web.Mvc/Models/NavigationViewModel{T}.cs
@@ -0,0 +1,34 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using OnTopic.Models;
+
+namespace OnTopic.Web.Mvc.Models {
+
+ /*============================================================================================================================
+ | VIEW MODEL: NAVIGATION TOPIC
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides a strongly-typed data transfer object for feeding views with information about a tier of navigation.
+ ///
+ ///
+ ///
+ /// No topics are expected to have a Navigation content type. Instead, this view model is expected to be manually
+ /// constructed by the .
+ ///
+ ///
+ /// The can be any view model that implements ,
+ /// which provides a base level of support for properties associated with the typical Page content type as well as
+ /// a method for determining if a given instance is the currently-selected
+ /// topic. Implementations may support additional properties, as appropriate.
+ ///
+ ///
+ public class NavigationViewModel where T: INavigationTopicViewModel {
+
+ public T NavigationRoot { get; set; }
+ public string CurrentKey { get; set; }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc/Models/TopicEntityViewModel.cs b/OnTopic.Web.Mvc/Models/TopicEntityViewModel.cs
new file mode 100644
index 0000000..7ed5222
--- /dev/null
+++ b/OnTopic.Web.Mvc/Models/TopicEntityViewModel.cs
@@ -0,0 +1,74 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using OnTopic.Repositories;
+
+namespace OnTopic.Web.Mvc.Models {
+
+ /*============================================================================================================================
+ | CLASS: TOPIC VIEW MODEL
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides a reference to an implementation as well as a current
+ /// associated with the current request.
+ ///
+ ///
+ /// Typically, views should be bound to view models which are, in effect, data transfer objects. They may hold references to
+ /// other view models, but all other properties of them and their related view models will be scalar values. On occasion,
+ /// however, some views may, for a variety of reasons, need full access to a and, in fact, the entire
+ /// topic graph via an implementation of . This is typically because the view requires full
+ /// access to the tree, or arbitrary access to the . In those cases, the provides a convenient wrapper for delivering both to the current page.
+ ///
+ public class TopicEntityViewModel {
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Initializes a new instance of a Topic View Model with appropriate dependencies.
+ ///
+ /// A Topic view model.
+ public TopicEntityViewModel(ITopicRepository topicRepository, Topic topic) {
+
+ TopicRepository = topicRepository;
+ Topic = topic;
+ RootTopic = topic;
+
+ while (RootTopic.Parent != null) {
+ RootTopic = RootTopic.Parent;
+ }
+
+ }
+
+ /*==========================================================================================================================
+ | PROPERTY: TOPIC REPOSITORY
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Returns the topic repository associated with the current request.
+ ///
+ /// The associated with the current request.
+ public ITopicRepository TopicRepository { get; }
+
+ /*==========================================================================================================================
+ | PROPERTY: ROOT TOPIC
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Returns the root topic associated with the object graph. This can be used to easily find other topics in the tree.
+ ///
+ /// The at the root of the object graph.
+ public Topic RootTopic { get; }
+
+ /*==========================================================================================================================
+ | PROPERTY: TOPIC
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Returns the topic associated with the current page based on the URL or route.
+ ///
+ /// The associated with the current page.
+ public Topic Topic { get; }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc/MvcTopicRoutingService.cs b/OnTopic.Web.Mvc/MvcTopicRoutingService.cs
new file mode 100644
index 0000000..96739b6
--- /dev/null
+++ b/OnTopic.Web.Mvc/MvcTopicRoutingService.cs
@@ -0,0 +1,103 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using System;
+using System.Web.Routing;
+using OnTopic.Internal.Diagnostics;
+using OnTopic.Repositories;
+
+namespace OnTopic.Web.Mvc {
+
+ /*============================================================================================================================
+ | CLASS: MVC TOPIC ROUTING SERVICE
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Given contextual information (including a URL and ) will determine the current Topic.
+ ///
+ ///
+ /// The is distributed with, and intended exclusively for use with, the ASP.NET MVC
+ /// version of OnTopic. As such, it makes no attempt to abstract out basic infrastructure components that ship with ASP.NET
+ /// MVC, such as the . That said, it also fully encapsulates them to ensure that they are not leaked
+ /// through the abstraction.
+ ///
+ public class MvcTopicRoutingService : ITopicRoutingService {
+
+ /*==========================================================================================================================
+ | PRIVATE VARIABLES
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ private readonly ITopicRepository _topicRepository = null;
+ private readonly RouteData _routes = null;
+ private readonly Uri _uri = null;
+ private Topic _topic = null;
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Initializes a new instance of the class based on a URL instance, a fully qualified
+ /// path to the views Directory, and, optionally, the expected filename suffix of each view file.
+ ///
+ public MvcTopicRoutingService(
+ ITopicRepository topicRepository,
+ Uri uri,
+ RouteData routeData
+ ) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Validate input
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ Contract.Requires(topicRepository, "A concrete implementation of an ITopicRepository is required.");
+ Contract.Requires(uri, "An instance of a Uri instantiated to the requested URL is required.");
+ Contract.Requires(routeData, "An instance of a RouteData dictionary is required. It can be empty.");
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Set values locally
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ _topicRepository = topicRepository;
+ _uri = uri;
+ _routes = routeData;
+
+ }
+
+ /*==========================================================================================================================
+ | METHOD: GET CURRENT TOPIC
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ public Topic GetCurrentTopic() {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Retrieve topic
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (_topic == null) {
+ var path = _uri.AbsolutePath;
+ if (_routes.Values.ContainsKey("path")) {
+ path = _routes.GetRequiredString("path");
+ if (_routes.Values.ContainsKey("rootTopic")) {
+ path = _routes.GetRequiredString("rootTopic") + "/" + path;
+ }
+ }
+ path = path.Trim(new char[] { '/' }).Replace("//", "/");
+ _topic = _topicRepository.Load(path.Replace("/", ":"));
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Set route data
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (_topic != null) {
+ if (_routes.Values.ContainsKey("contenttype")) {
+ _routes.Values.Remove("contenttype");
+ }
+ _routes.Values.Add("contenttype", _topic.ContentType);
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Return Topic
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ return _topic;
+
+ }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc/OnTopic.Web.Mvc.csproj b/OnTopic.Web.Mvc/OnTopic.Web.Mvc.csproj
new file mode 100644
index 0000000..e32d502
--- /dev/null
+++ b/OnTopic.Web.Mvc/OnTopic.Web.Mvc.csproj
@@ -0,0 +1,59 @@
+
+
+ {3B3CE34D-B5E5-47CA-BFEF-E6740650F378}
+ net48
+ True
+ False
+
+
+ Ignia OnTopic MVC Library
+ Ignia
+ Ignia OnTopic Library
+ Provides presentation-layer support for the ASP.NET MVC Framework.
+ Copyright © 2015 Ignia, LLC
+ bin\$(Configuration)\
+ Ignia
+
+
+ https://github.com/Ignia/Topics-Library
+ C# .NET CMS Presentation Web MVC ASP.NET Controller
+ true
+
+
+ full
+ false
+ latest
+ 1701;1702;CA1303
+
+
+ pdbonly
+ 1701;1702;CA1303
+
+
+
+ all
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
+
+
+
+ all
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc/Properties/AssemblyInfo.cs b/OnTopic.Web.Mvc/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..0c464fa
--- /dev/null
+++ b/OnTopic.Web.Mvc/Properties/AssemblyInfo.cs
@@ -0,0 +1,16 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using System;
+using System.Runtime.InteropServices;
+
+/*==============================================================================================================================
+| DEFINE ASSEMBLY ATTRIBUTES
+>===============================================================================================================================
+| Declare and define attributes used in the compiling of the finished assembly.
+\-----------------------------------------------------------------------------------------------------------------------------*/
+[assembly: ComVisible(false)]
+[assembly: CLSCompliant(true)]
+[assembly: Guid("3b3ce34d-b5e5-47ca-bfef-e6740650f378")]
diff --git a/OnTopic.Web.Mvc/TopicViewEngine.cs b/OnTopic.Web.Mvc/TopicViewEngine.cs
new file mode 100644
index 0000000..52cc0eb
--- /dev/null
+++ b/OnTopic.Web.Mvc/TopicViewEngine.cs
@@ -0,0 +1,217 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Web.Mvc;
+using OnTopic.Internal.Diagnostics;
+
+namespace OnTopic.Web.Mvc {
+
+ /*============================================================================================================================
+ | CLASS: TOPIC VIEW ENGINE
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides a custom which provides custom support for organizing views by
+ /// .
+ ///
+ public class TopicViewEngine : RazorViewEngine {
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Initializes a new instance of .
+ ///
+ ///
+ /// When instantiated, the constructor will initialize location formats with
+ /// extensions intended to support organizing views by .
+ ///
+ public TopicViewEngine(IViewPageActivator viewPageActivator = null) : base(viewPageActivator) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Define view location
+ >-------------------------------------------------------------------------------------------------------------------------
+ | Supports the following replacement tokens: {0} Controller, {1} View, {2} Area, and {3} Content Type.
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var viewLocations = new[] {
+ "~/Views/{3}/{1}.cshtml",
+ "~/Views/ContentTypes/{3}.{1}.cshtml",
+ "~/Views/ContentTypes/{1}.cshtml",
+ "~/Views/Shared/{1}.cshtml",
+ };
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Set view locations
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ ViewLocationFormats = ViewLocationFormats.Union(viewLocations).ToArray();
+ MasterLocationFormats = MasterLocationFormats.Union(viewLocations).ToArray();
+ PartialViewLocationFormats = PartialViewLocationFormats.Union(viewLocations).ToArray();
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Update view locations for areas
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ viewLocations = viewLocations.Select(v => v.Replace("~", "~/{2}/")).ToArray();
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Set area view locations
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ AreaViewLocationFormats = AreaViewLocationFormats.Union(viewLocations).ToArray();
+ AreaMasterLocationFormats = AreaMasterLocationFormats.Union(viewLocations).ToArray();
+ AreaPartialViewLocationFormats = AreaPartialViewLocationFormats.Union(viewLocations).ToArray();
+
+ }
+
+ /*==========================================================================================================================
+ | METHOD: FIND PARTIAL VIEW
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provided a partial view name, determines if the view exists and, if it does, returns a new instance of it.
+ ///
+ ///
+ /// Compared to the base , this override will look for {3}
in the path pattern
+ /// and attempt to replace it with the contenttype
.
+ ///
+ /// The current .
+ /// The requested name of the view.
+ /// The requested name of the master (layout) view.
+ /// Determines whether the request is appropriate for caching.
+ public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Validate parameters
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ Contract.Requires(controllerContext, nameof(controllerContext));
+ Contract.Requires(partialViewName, nameof(partialViewName));
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Identify search paths
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var searchPaths = GetSearchPaths(controllerContext, partialViewName, PartialViewLocationFormats);
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Loop through patterns to identify views
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ foreach (var path in searchPaths) {
+ if (FileExists(controllerContext, path)) {
+ return new ViewEngineResult(
+ CreatePartialView(controllerContext, path),
+ this
+ );
+ }
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Provide base processing, if view was not found
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ return new ViewEngineResult(searchPaths.ToArray());
+
+ }
+
+ /*==========================================================================================================================
+ | METHOD: FIND VIEW
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provided a view name, determines if the view exists and, if it does, returns a new instance of it.
+ ///
+ ///
+ /// Compared to the base , this override will look for {3}
in the path pattern
+ /// and attempt to replace it with the contenttype
.
+ ///
+ /// The current .
+ /// The requested name of the view.
+ /// The requested name of the master (layout) view.
+ /// Determines whether the request is appropriate for caching.
+ public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Validate parameters
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ Contract.Requires(controllerContext, nameof(controllerContext));
+ Contract.Requires(viewName, nameof(viewName));
+ Contract.Requires(masterName, nameof(masterName));
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Identify search paths
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var searchPaths = GetSearchPaths(controllerContext, viewName, ViewLocationFormats);
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Loop through patterns to identify views
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ foreach (var path in searchPaths) {
+ if (FileExists(controllerContext, path)) {
+ return new ViewEngineResult(
+ CreateView(controllerContext, path, masterName),
+ this
+ );
+ }
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Provide base processing, if view was not found
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ return new ViewEngineResult(searchPaths.ToArray());
+
+ }
+
+ /*==========================================================================================================================
+ | METHOD: GET SEARCH PATHS
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provided the name of the view, and the context of the controller, identifies the paths that should be searched.
+ ///
+ ///
+ /// Compared to the base , this override will look for {3}
in the path pattern
+ /// and attempt to replace it with the contenttype
.
+ ///
+ /// The current .
+ /// The requested name of the view.
+ /// The list of path format patterns.
+ private static List GetSearchPaths(ControllerContext controllerContext, string viewName, string[] locationFormats) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Establish variables
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var routeData = controllerContext.RouteData;
+ var area = "";
+ var controller = "Topic";
+ var contentType = (object)null;
+ var searchPaths = new List();
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Validate dependencies
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (routeData.Values.ContainsKey("area")) {
+ area = routeData.GetRequiredString("area");
+ }
+ if (routeData.Values.ContainsKey("controller")) {
+ controller = routeData.GetRequiredString("controller");
+ }
+ if (routeData.Values.ContainsKey("contenttype")) {
+ routeData.Values.TryGetValue("contenttype", out contentType);
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Loop through patterns to identify views
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ foreach (var pathPattern in locationFormats) {
+ if (!pathPattern.Contains("{3}") || !String.IsNullOrEmpty((string)contentType)) {
+ var path = String.Format(CultureInfo.InvariantCulture, pathPattern, controller, viewName, area, contentType);
+ searchPaths.Add(path);
+ }
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Return list of views
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ return searchPaths;
+
+ }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/OnTopic.Web.Mvc/TopicViewResult.cs b/OnTopic.Web.Mvc/TopicViewResult.cs
new file mode 100644
index 0000000..c258c98
--- /dev/null
+++ b/OnTopic.Web.Mvc/TopicViewResult.cs
@@ -0,0 +1,168 @@
+/*==============================================================================================================================
+| Author Ignia, LLC
+| Client Ignia, LLC
+| Project Topics Library
+\=============================================================================================================================*/
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Web.Mvc;
+using OnTopic.Internal.Diagnostics;
+using OnTopic.Models;
+
+namespace OnTopic.Web.Mvc {
+
+ /*============================================================================================================================
+ | CLASS: TOPIC VIEW RESULT
+ \---------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Provides a custom version of capable of determining the most appropriate view.
+ ///
+ public class TopicViewResult : ViewResult {
+
+ /*==========================================================================================================================
+ | PRIVATE VARIABLES
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ readonly string _contentType = "";
+ readonly string _topicView = "";
+
+ /*==========================================================================================================================
+ | CONSTRUCTOR
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Constructs a new instances of a based on the and
+ /// .
+ ///
+ ///
+ /// If the is unavailable, it is assumed to be Page . If the is unavailable, it is assumed to be the same as the .
+ ///
+ public TopicViewResult(ITopicViewModel viewModel) : base() {
+ Contract.Requires(viewModel, nameof(viewModel));
+ ViewData.Model = viewModel;
+ _contentType = viewModel.ContentType ?? "Page";
+ _topicView = viewModel.View ?? _contentType;
+ }
+
+ ///
+ /// Constructs a new instances of a based on a supplied and,
+ /// optionally, .
+ ///
+ ///
+ /// If the is not provided, it is assumed to be Page . If the
+ /// is not provided, it is assumed to be .
+ ///
+ public TopicViewResult(object viewModel, string contentType = "Page", string view = null) : base() {
+ ViewData.Model = viewModel;
+ _contentType = contentType;
+ _topicView = view ?? _contentType;
+ }
+
+ /*==========================================================================================================================
+ | METHOD: FIND VIEW
+ \-------------------------------------------------------------------------------------------------------------------------*/
+ ///
+ /// Loops through potential sources for views to identify the most appropriate .
+ ///
+ ///
+ /// Will look for a view, in order, from the query string (?View=
),
+ /// collection (for matches in the accepts
header), then the property, if set, and
+ /// finally falls back to the . If none of those yield any results, will default to a
+ /// content type of "Page", which expects to find ~/Views/Page/Page.cshtml
.
+ ///
+ protected override ViewEngineResult FindView(ControllerContext context) {
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Validate parameters
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ Contract.Requires(context, nameof(context));
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Set variables
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ var contentType = _contentType;
+ var viewEngine = ViewEngines.Engines;
+ var requestContext = context.HttpContext.Request;
+ var view = new ViewEngineResult(Array.Empty());
+ var searchedPaths = new List();
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Check Querystring
+ >-------------------------------------------------------------------------------------------------------------------------
+ | Determines if the view is defined in the querystring.
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (view.View == null && requestContext.QueryString.AllKeys.Contains("View")) {
+ var queryStringValue = requestContext.QueryString["View"];
+ if (queryStringValue != null) {
+ view = viewEngine.FindView(context, queryStringValue, MasterName);
+ searchedPaths = searchedPaths.Union(view.SearchedLocations?? Array.Empty()).ToList();
+ }
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Pull Headers
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (view.View == null && requestContext.Headers.AllKeys.Contains("Accept")) {
+ var acceptHeaders = requestContext.Headers.GetValues("Accept");
+ // Validate the content-type after the slash, then validate it against available views
+ var splitHeaders = acceptHeaders[0].Split(new char[] { ',', ';' });
+ // Validate the content-type after the slash, then validate it against available views
+ for (var i = 0; i < splitHeaders.Length; i++) {
+ if (splitHeaders[i].IndexOf("/", StringComparison.InvariantCultureIgnoreCase) >= 0) {
+ // Get content-type after the slash and replace '+' characters in the content-type to '-' for view file encoding
+ // purposes
+ var acceptHeader = splitHeaders[i]
+ .Substring(splitHeaders[i].IndexOf("/", StringComparison.InvariantCulture) + 1)
+ .Replace("+", "-");
+ // Validate against available views; if content-type represents a valid view, stop validation
+ if (acceptHeader != null) {
+ view = viewEngine.FindView(context, acceptHeader, MasterName);
+ searchedPaths = searchedPaths.Union(view.SearchedLocations ?? Array.Empty()).ToList();
+ }
+ if (view != null) {
+ break;
+ }
+ }
+ }
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Pull from topic attribute
+ >-------------------------------------------------------------------------------------------------------------------------
+ | Pull from Topic's View Attribute; additional check against the Topic's ContentType Topic View Attribute is not necessary
+ | as it is set as the default View value for the Topic
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (view.View == null && !String.IsNullOrEmpty(_topicView)) {
+ view = viewEngine.FindView(context, _topicView, MasterName);
+ searchedPaths = searchedPaths.Union(view.SearchedLocations ?? Array.Empty()).ToList();
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Default to content type
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (view.View == null) {
+ view = viewEngine.FindView(context, contentType, MasterName);
+ searchedPaths = searchedPaths.Union(view.SearchedLocations ?? Array.Empty()).ToList();
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Attempt default search
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (view.View == null) {
+ view = base.FindView(context);
+ searchedPaths = searchedPaths.Union(view.SearchedLocations ?? Array.Empty()).ToList();
+ }
+
+ /*------------------------------------------------------------------------------------------------------------------------
+ | Return view, if found
+ \-----------------------------------------------------------------------------------------------------------------------*/
+ if (view.View != null) {
+ return view;
+ }
+ return new ViewEngineResult(searchedPaths);
+
+ }
+
+ } //Class
+} //Namespace
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..17574d6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,116 @@
+# `Ignia.Topics.Web.Mvc`
+The `Ignia.Topics.Web.Mvc` assembly provides an implementation of OnTopic for use with the ASP.NET MVC 5.x Framework.
+
+### Contents
+- [Components](#components)
+- [Controllers](#controllers)
+- [View Conventions](#view-conventions)
+ - [View Matching](#view-matching)
+ - [View Locations](#view-locations)
+ - [Example](#example)
+- [Configuration](#configuration)
+ - [Application](#application)
+ - [Route Configuration](#route-configuration)
+ - [Controller Factory](#controller-factory)
+
+## Components
+There are three key components at the heart of the MVC implementation.
+- **`MvcTopicRoutingService`**: This is a concrete implementation of the `ITopicRoutingService` which accepts contextual information about a given request (in this case, the URL and routing data) and then uses it to retrieve the current `Topic` from an `ITopicRepository`.
+- **`TopicController`**: This is a default controller instance that can be used for _any_ topic path. It will automatically validate that the `Topic` exists, that it is not disabled (`IsDisabled`), and will honor any redirects (e.g., if the `Url` attribute is filled out). Otherwise, it will return `TopicViewResult` based on a view model, view name, and content type.
+- **`TopicViewEngine`**: The `TopicViewEngine` is called every time a view is requested. It works in conjunction with `TopicViewResult` to identify matching MVC views based on predetermined locations and conventions. These are discussed below.
+
+## Controllers
+There are six main controllers that ship with the MVC implementation. In addition to the core **`TopicController`**, these include the following ancillary controllers:
+- **`ErrorControllerBase`**: Provides support for `Error`, `NotFound`, and `InternalServer` actions. Can accept any `IPageTopicViewModel` as a generic argument; that will be used as the view model.
+- **`FallbackController`**: Used in a [Controller Factory](#controller-factory) as a fallback, in case no other controllers can accept the request. Simply returns a `NotFoundResult` with a predefined message.
+- **`LayoutControllerBase`**: Provides support for a navigation menu by automatically mapping the top three tiers of the current namespace (e.g., `Web`, its children, and grandchildren). Can accept any `INavigationTopicViewModel` as a generic argument; that will be used as the view model for each mapped instance.
+- **`RedirectController`**: Provides a single `Redirect` action which can be bound to a route such as `/Topic/{ID}/`; this provides support for permanent URLs that are independent of the `GetWebPath()`.
+- **`SitemapController`**: Provides a single `Sitemap` action which returns a reference to the `ITopicRepository`, thus allowing a sitemap view to recurse over the entire Topic graph, including all attributes.
+
+> **Note:** There is not a practical way for MVC to provide routing for generic controllers. As such, these _must_ be subclassed by each implementation. The derived controller needn't do anything outside of provide a specific type reference to the generic base.
+
+## View Conventions
+By default, OnTopic matches views based on the current topic's `ContentType` and, if available, `View`.
+
+### View Matching
+There are multiple ways for a view to be set. The `TopicViewResult` will automatically evaluate views based on the following locations. The first one to match a valid view name is selected.
+- **`?View=`** query string parameter (e.g., `?View=Accordion`)
+- **`Accept`** headers (e.g., `Accept=application/json`); will treat the segment after the `/` as a possible view name
+- **`View`** attribute (i.e., `topic.View`)
+- **`ContentType`** attribute (i.e., `topic.ContentType`)
+
+### View Locations
+For each of the above [View Matching](#view-matching) rules, the `TopicViewEngine` will search the following locations for a matching view:
+- `~/Views/{ContentType}/{View}.cshtml`
+- `~/Views/ContentTypes/{ContentType}.{View}.cshtml`
+- `~/Views/ContentTypes/{ContentType}.cshtml`
+- `~/Views/Shared/{View}.cshtml`
+
+> *Note:* After searching each of these locations for each of the [View Matching](#view-matching) rules, control will be handed over to the [`RazorViewEngine`](https://msdn.microsoft.com/en-us/library/system.web.mvc.razorviewengine%28v=vs.118%29.aspx?f=255&MSPPError=-2147217396), which will search the out-of-the-box default locations for ASP.NET MVC.
+
+### Example
+If the `topic.ContentType` is `ContentList` and the `Accept` header is `application/json` then the `TopicViewResult` and `TopicViewEngine` would coordinate to search the following paths:
+- `~/Views/ContentList/JSON.cshtml`
+- `~/Views/ContentTypes/ContentList.JSON.cshtml`
+- `~/Views/ContentTypes/JSON.cshtml`
+- `~/Views/Shared/JSON.cshtml`
+
+If no match is found, then the next `Accept` header will be searched. Eventually, if no match can be found on the various [View Matching](#view-matching) rules, then the following will be searched:
+
+- `~/Views/ContentList/ContentList.cshtml`
+- `~/Views/ContentTypes/ContentList.ContentList.cshtml`
+- `~/Views/ContentTypes/ContentList.cshtml`
+- `~/Views/Shared/ContentList.cshtml`
+
+## Configuration
+
+### Application
+In the `global.asax.cs`, the following components should be registered under the `Application_Start` event handler:
+```
+ControllerBuilder.Current.SetControllerFactory(new OrganizationNameControllerFactory());
+ViewEngines.Engines.Insert(0, new TopicViewEngine());
+```
+> *Note:* The controller factory name is arbitrary, and should follow the conventions appropriate for the site. Ignia typically uses `{OrganizationName}ControllerFactory` (e.g., `IgniaControllerFactory`), but OnTopic doesn't need to know or care what the name is; that is between your application and the ASP.NET MVC Framework.
+
+### Route Configuration
+When registering routes via `RouteConfig.RegisterRoutes()` (typically via the `RouteConfig` class), register a route for any OnTopic routes:
+```
+routes.MapRoute(
+ name: "WebTopics",
+ url: "Web/{*path}",
+ defaults: new { controller = "Topic", action = "Index", id = UrlParameter.Optional, rootTopic = "Web" }
+);
+```
+> *Note:* Because OnTopic relies on wildcard pathnames, a new route should be configured for every root namespace (e.g., `/Web`). While it's possible to configure OnTopic to evaluate _all_ paths, this makes it difficult to delegate control to other controllers and handlers, when necessary.
+
+### Controller Factory
+As OnTopic relies on constructor injection, the application must be configured in a **Composition Root**—in the case of ASP.NET MVC, that means a custom controller factory. The basic structure of this might look like:
+```
+var connectionString = ConfigurationManager.ConnectionStrings["OnTopic"].ConnectionString;
+var sqlTopicRepository = new SqlTopicRepository(connectionString);
+var cachedTopicRepository = new CachedTopicRepository(sqlTopicRepository);
+var topicViewModelLookupService = new TopicViewModelLookupService();
+var topicMappingService = new TopicMappingService(cachedTopicRepository, topicViewModelLookupService);
+
+var mvcTopicRoutingService = new MvcTopicRoutingService(
+ cachedTopicRepository,
+ requestContext.HttpContext.Request.Url,
+ requestContext.RouteData
+);
+
+switch (controllerType.Name) {
+
+ case nameof(TopicController):
+ return new TopicController(sqlTopicRepository, mvcTopicRoutingService, topicMappingService);
+
+ case default:
+ return base.GetControllerInstance(requestContext, controllerType);
+
+}
+
+```
+For a complete reference template, including the ancillary controllers, see the [`OrganizationNameControllerFactory.cs`](https://gist.github.com/JeremyCaney/6ba4bb0465b7dd1992a7ffdaa1ebf813) Gist.
+
+> *Note:* The default `TopicController` will automatically identify the current topic (based on e.g. the URL), map the current topic to a corresponding view model (based on [the `TopicMappingService` conventions](../Ignia.Topics/Mapping/)), and then return a corresponding view (based on the [view conventions](#view-conventions)). For most applications, this is enough. If custom mapping rules or additional presentation logic are needed, however, implementors can subclass `TopicController`.
+
+