Обслуживание страниц Markdown как HTML на любом базовом сайте ASP.NET

Первую часть можно прочитать здесь.

Когда мы работаем над сайтом, неплохо иметь под рукой простой способ управлять разделом с документацией или блогом, просто перекидывая Markdown-файлы в нужную папку, которые затем используются на этих страницах в качестве контента, аккуратно вплетенного в айдентику бренда на нашем сайте, не так ли? В этой идее нет ничего нового — большинство CMS-систем и генераторов контента именно так и делают. Но это, как правило, специализированные инструменты, обособленные от нашего веб-приложения. Вот бы здорово было, если бы у нас был простой способ добавить поддержку Markdown на страницах нашего уже существующего веб-приложения?

Давайте же взглянем на middleware-компонент ASP.NET Core, который упрощает настройку папок для Markdown-файлов, которые используются в качестве контента нашего веб-сайта.

Большинство динамических сайтов содержат как минимум пару страниц с контентом, которые в основном статичны, а потому их легче создавать с помощью Markdown, а не “тегового супа” HTML.

Что нам нужно для работы с Markdown-страницами?

Вот что нам потребуется для работы со статическими Markdown-страницами:

  • Страница-оболочка, реализующая пользовательский интерфейс нашего сайта вокруг Markdown-контента

  • Область контента, в которую будет рендериться результирующий HTML

  • Файл с Markdown-текстом, который мы собираемся рендерить 

  • (Опционально) YAML-парсинг для тайтла и хедеров

  • (Опционально) парсинг тайтла на основе хедера или имени файла.

  • Конфигурация папок для шаблона и параметров 

Весь наш замысел предельно прост: для каждой сконфигурированной иерархии папок мы можем указать шаблон Razor, представляющий из себя страницу для размещения Markdown-контента. Эта страница достаточно примитивна — на практике, для большинства сайтов на этой странице не будет ничего, кроме встроенного Markdown-контента и ссылки на страницу _layout.cshtml нашего сайта.

В этот шаблон Markdown-страницы передается модель, содержащая отрендеренный Markdown-текст, который мы затем можем встроить в шаблон в нужном месте. Мы можем создать шаблон любым удобным для нас способом: либо в виде отдельной HTML-страницы, либо просто ссылаясь на мастер-страницу, встроив свойство @Model.RenderedMarkdown в страницу.

Нам также нужно создать конфигурацию для подключения middleware, которое указывает, с какими папками (или root) работать, и хотим ли мы обрабатывать URL без расширений в дополнение к обработке расширения .md.

Затем мы можем просто добавить файлы с расширением .md в папку wwwroot нашего сайта и сконфигурировать пути, точно так же как мы делали бы это со статическими html-файлами.

Приступая к работе

Если вы хотите пощупать middleware, которое я описываю в этой статье, вы можете установить его отсюда:

PM> Install-Package Westwind.AspNetCore.Markdown

Настройка Markdown Middleware

Мы можем начать использовать middleware сразу после установки соответствующего NuGet-пакета. Это делается путем подключения конфигурации middleware:

  • Запустим AddMarkdown() для конфигурации обработки страниц.

  • Запустим UseMarkdown() для подключения middleware.

  • Создадим шаблон представления Markdown (по умолчанию: ~/Views/__MarkdownPageTemplate.cshtml).

  • Создадим .md-файлы для нашего контента.

Следующий код сконфигурирует папку /posts/ для работы с Markdown-документами в вашем приложении: 

public void ConfigureServices(IServiceCollection services)
{
    services.AddMarkdown(config =>
    {
        config.AddMarkdownProcessingFolder("/posts/","~/Pages/__MarkdownPageTemplate.cshtml");
    });
    services.AddMvc();
}

В рамках этой базовой конфигурации мы указываем путь к папке, в которой будут размещены Markdown-документы. Мы можем сконфигурировать несколько папок, включая даже root (/). Мы также можем указать Razor-шаблон, который будет использоваться в качестве контейнера для markdown-контента.

Приведенный выше код является простым примером, не включающим некоторые дополнительные параметры, которые мы можем использовать в нашей конфигурации. Мы можем задать в конфигурации несколько путей, указать, как должны обрабатываться файлы, настроить предварительную обработку кода, предоставив обработчик, который будет запускаться перед рендерингом Markdown-документов, и мы также можем изменить конфигурацию нашего Markdown-конвейера MarkDig.

Затем нам нужно подключить это middleware к middleware-конвейеру с помощью app.UseMarkdown() в методе Configure() Startup:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseMarkdown();
    app.UseStaticFiles();
    // MVC необходимо для рендеринга Razor-шаблона
    app.UseMvc();
}

Создание Razor-страницы в качестве Markdown-контейнера

Markdown-middleware требует MVC для рендеринга Markdown-контента, чтобы переписать путь запроса и поместить текущий путь запроса в кастомный контроллер, предоставляемый этим компонентом. Затем контроллер рендерит Razor-шаблон, который мы указали в конфигурации AddMarkdown(), показанной ранее.

Наиболее примитивный шаблонов выглядит так:

@modelWestwind.AspNetCore.Markdown.MarkdownModel
@{
    ViewBag.Title = Model.Title;
    Layout = "_Layout";
}
<div style="margin-top: 40px;">
    @Model.RenderedMarkdown
</div>

В модели содержится два интересующих нас значения: RenderedMarkdown и Title, которые можно встроить в шаблон. Title выводится из YAML-хедера title, если таковой имеется, или из первого заголовочного тега # в документе. Если ничего из это нет, то тайтл остается не установленным. Если вы хотите передать другие значения в свое представление, конфигурация также поддерживает хук для предварительной обработки, который подключается к Markdown-запросу:

folderConfig.PreProcess = (model, controller) =>
{
    // controller.ViewBag.Model = new MyCustomModel();
};

Модель также имеет свойства относительного и физического пути и обеспечивает доступ к конфигурации активной папки, поэтому ваш код предварительной обработки может выполнять сложную логику в зависимости от того, какой файл активирован, и может передавать эти данные в ваше представление через свойства контроллера ViewBag или ViewData.

На этом этапе мы уже можем начинать добавлять Markdown-файлы в нашу папку /wwwroot/posts/. Мы создадим иерархию папок, которая соответствует типичной структуре поста в блоге, где будет дата и имя сообщения, как показано на рисунке 2, и добавим в нее один пост из моего блога вместе с парой изображений.

Рисунок 2: Добавление markdown-файла с контентом в папку для рендеринга
Рисунок 2: Добавление markdown-файла с контентом в папку для рендеринга

Это все, что нужно, и теперь мы можем получить доступ к этой странице по следующему URL:

http://localhost:59805/posts/2018/03/23/MarkdownTagHelper.md

Или версия без расширения:

http://localhost:59805/posts/2018/03/23/MarkdownTagHelper

Конфигурация по умолчанию работает как с расширением .md, так и без него. Если расширение не указано, middleware просматривает каждый запрос без расширения, пытается добавить .md, проверяет, существует ли файл, и рендерит его. Результат показан на рисунке 3.

Рисунок 3: Отрендеренный Markdown-документ на стандартном ASP.NET Core сайте
Рисунок 3: Отрендеренный Markdown-документ на стандартном ASP.NET Core сайте

Markdown-контент корректно отображается в виде HTML, и, как и ожидалось, мы получили соответствующий пользовательский интерфейс шаблонного ASP.NET Core сайта. Мы не вносили никаких изменений в стандартный шаблон New Project, но даже в этом случае этот markdown-текст прекрасно отображается на странице, согласуясь с контекстом пользовательского интерфейса сайта.

Еще пара улучшений

Есть одна вещь, которую определенно стоило бы улучшить: наш пример кода отображается скучным монохромным текстом без подсветки синтаксиса. Чтобы исправить это, мы теперь можем изменить наш Razor-шаблон, добавив немного JavaScript, чтобы реализовать подсветку синтаксиса, и добавить немного Bootstrap в наш стиль, чтобы он выглядел лучше. В листинге 2 показан наш обновленный шаблон:

Листинг 2: Razor-шаблон с подсветкой синтаксиса для HighlightJs

@model Westwind.AspNetCore.Markdown.MarkdownModel
@{
    ViewBag.Title = Model.Title;
    Layout = "_Layout";
}
@section Headers {
    <style>
        h3 {
           margin-top: 50px;
           padding-bottom: 10px;
           border-bottom: 1px solid #eee;
        }
        /* vs2015 theme specific*/
        pre {
            background: #1E1E1E;
            color: #eee;
            padding: 0.5em !important;
            overflow-x: auto;
            white-space: pre;
            word-break: normal;
            word-wrap: normal;
        }

        pre > code {
            white-space: pre;
        }
    </style>
}
<div style="margin-top: 40px;">
    @Model.RenderedMarkdown
</div>

@section Scripts {
    <script src="~/lib/highlightjs/highlight.pack.js"></script>
    <link href="~/lib/highlightjs/styles/vs2015.css" />
    <script>
        setTimeout(function () {
            var pres = document.querySelectorAll("pre>code");
            for (var i = 0; i < pres.length; i++) {hljs.highlightBlock(pres[i]);}
        });
    </script>
}

На рисунке 4 показано, как сейчас выглядит отрендеренная страница:

Рисунок 4: Новая улучшенная страница
Рисунок 4: Новая улучшенная страница

Для форматирования и подсветки кода мы использовали JavaScript-библиотеку highlightJS. Чтобы использовать эту библиотеку от нас требуется ссылка на нее, тема (VS2015, тема, похожая на темную тему Visual Studio Code) и небольшой скрипт, который находит pre>code элементы на странице и применяет подсветку синтаксиса. Отрендеренный Markdown также включает атрибут языка, который HighlightJS понимает и использует для выбора соответствующего поддерживаемого языка. HighlightJS можно кастомизировать, и мы также можем создавать собственные пакеты, включающие нужные нам языки. Я создал свой собственный пакет, включающий наиболее распространенные языки .NET, Windows и Web, который вы можете найти в GitHub-репозитории библиотеки.

На данном этапе я могу просто поместить файлы Markdown в свою папку wwwroot/posts/, и они будут отображаться как вполне самодостаточные страницы. Чудеса!

Создание собственного middleware для обработки Markdown-файлов

Так как же все это работает? Как вы, наверное, и сами догадываетесь, процесс создания middleware не очень сложен, но он включает довольно много подвижных частей, что является привычным делом при создании middleware.

Вот что нам потребуется:

  • Реализация middleware для обработки маршрутизации запросов.

  • Middleware-расширения, которые настраивают и подключают middleware.

  • Контроллер MVC, который обрабатывает запрос рендеринга

  • Razor-шаблон, используемый для визуализации отрендеренного HTML

Краткий справка по middleware 

Middleware — это класс ASP.NET Core, который реализует метод InvokeAsync (HttpContext context). С другой стороны, Middleware также может быть реализовано непосредственно в классе Startup или как часть Middleware-расширения через app.Use() (для завершения middleware  app.Run()).

Идея, стоящая за middleware довольно проста: вы реализуете обработчик middleware, который получает объект context и вызывает next(context), который передает контекст следующему middleware, определенному в цепочке, которое вызывает следующее middleware, и так далее, пока все middleware-компоненты не будут вызваны. Цепочка разворачивается, и каждый из этих вызовов возвращает свой статус обратно вверх по цепочке. По сути, обработчики middleware могут перехватывать входящие и исходящие запросы с помощью одной и той же реализации. Рисунок 5 иллюстрирует поток этого связанного взаимодействия middleware-компонентов. Обратите внимание, что порядок в конвейере играет важную роль значение. Таким образом, для правильной работы функция предварительной обработки, такая как аутентификация или CORS, должна выполняться до функции обслуживания страниц, такой как обслуживание MVC или StaticFile.

Рисунок 5: Middleware-компоненты подключаются к обработке входящих и исходящих запросов
Рисунок 5: Middleware-компоненты подключаются к обработке входящих и исходящих запросов

Реализация специального middleware-компонента обычно подразумевает создание самого middleware-компонента, а также нескольких middleware-расширений, используемых для конфигурации и подключения middleware с помощью app.Add<Middleware>() и app.Use<middleware>(), которые являются шаблоном, используемым большинством встроенных middleware-компонентов ASP.NET Core.

Реализация обработки Markdown-страниц в виде middleware

Основная задача нашего middleware для обработки Markdown-страниц — выяснить, запрашивает ли входящий запрос Markdown-документ, проверяя URL. Если запрос указывает на .md-файл Markdown, middleware фактически переписывает URL запроса и направляет его на кастомный хорошо известный эндпоинт контроллера, который предоставляется как часть библиотеки компонентов. Листинге 3 демонстрирует, как выглядит middleware.

Листинг 3: Middleware-компонент для обслуживания Markdown-страниц

public class MarkdownPageProcessorMiddleware
{
private readonly RequestDelegate _next;
private readonly MarkdownConfiguration _configuration;
private readonly IHostingEnvironment _env;

public MarkdownPageProcessorMiddleware(RequestDelegate next, 
                                       MarkdownConfiguration configuration,
                                       IHostingEnvironment env)
{
    _next = next;
    _configuration = configuration;
    _env = env;
}

public Task InvokeAsync(HttpContext context)
{
    var path = context.Request.Path.Value;
    if (path == null)
        return _next(context);

    bool hasExtension = !string.IsNullOrEmpty(Path.GetExtension(path));
    bool hasMdExtension = path.EndsWith(".md");
    bool isRoot = path == "/";
    bool processAsMarkdown = false;

    var basePath = _env.WebRootPath;
    var relativePath = path;
    relativePath = PathHelper
                      .NormalizePath(relativePath)
                      .Substring(1);
    var pageFile = Path.Combine(basePath, relativePath);

    // обрабатывает любой файл С расширением .md
    foreach (var folder in 
               _configuration.MarkdownProcessingFolders)
    {
        if (!path.StartsWith(folder.RelativePath, 
            StringComparison.InvariantCultureIgnoreCase))
            continue;

        if (isRoot && folder.RelativePath != "/")
            continue;

        if (context.Request.Path.Value.EndsWith(".md", 
            StringComparison.InvariantCultureIgnoreCase))
        {
            processAsMarkdown = true;
        }
        else if (path.StartsWith(folder.RelativePath, 
             StringComparison.InvariantCultureIgnoreCase) &&
             (folder.ProcessExtensionlessUrls && !hasExtension ||
              hasMdExtension && folder.ProcessMdFiles))
        {
            if (!hasExtension && Directory.Exists(pageFile))
                continue;

            if (!hasExtension)
                pageFile += ".md";

            if (!File.Exists(pageFile))
                continue;

            processAsMarkdown = true;
        }

        if (processAsMarkdown)
        {
            // значения в пуше из контроллера
            context.Items["MarkdownPath_PageFile"] = pageFile;
            context.Items["MarkdownPath_OriginalPath"] = path;
            context.Items["MarkdownPath_FolderConfiguration"] = folder;

            // перезаписывает путь к нашему контроллеру 
            context.Request.Path = "/markdownprocessor/markdownpage";
            break;
        }
    }
    return _next(context);
} }

Очень важный элемент в этом middleware  — Context.Path.Value, который содержит текущий путь запроса. На основе этого пути компонент проверяет, указывает ли он либо на .md напрямую, либо на URL без расширения, к которому он добавляет .md, и проверяет наличие файла.

Если путь указывает на Markdown-файл, middleware устанавливает флаг и сохраняет исходный путь и путь к файлу в нескольких элементах контекста. Что еще более важно, он перзаписывает текущий путь, чтобы указать на хорошо известный MarkdownPageProcessorController, который имеет фиксированный маршрут /markdownprocessor/markdownpage.

Переписать путь запроса в middleware в ASP.NET Core так же просто, как изменить свойство HttpContext.Path.

Универсальный Markdown-контроллер 

Это перенаправит запрос на мой кастомный контроллер, который затем может отображать содержимое физического файла с использованием сконфигурированного Razor-шаблона:

[Route("markdownprocessor/markdownpage")]
public async Task<IActionResult> MarkdownPage()

Этот захардкоженный маршрут атрибутов будет найден, даже если он находится в отдельной библиотеке, получаемой через NuGet. Обратите внимание, что этот маршрут работает только в сочетании с middleware, поскольку он зависит от предустановленных значений Context.Items, которые были сохранены middleware ранее в запросе. В листинге 4 показан экшн-метод, отвечающий за обслуживание Markdown-файла.

Листинг 4: Метод Controller, который обрабатывает Markdown-контент

[Route("markdownprocessor/markdownpage")]
public async Task<IActionResult> MarkdownPage()
{
    var basePath = hostingEnvironment.WebRootPath;
    var relativePath = HttpContext.Items["MarkdownPath_OriginalPath"] as string;
    if (relativePath == null)
        return NotFound();

    var folderConfig =
            HttpContext.Items["MarkdownPath_FolderConfiguration"] 
            as MarkdownProcessingFolder;
    var pageFile = HttpContext.Items["MarkdownPath_PageFile"] as string;
    if (!System.IO.File.Exists(pageFile))
        return NotFound();

    // string markdown = await File.ReadAllTextAsync(pageFile);
    string markdown;
    using (var fs = new FileStream(pageFile, FileMode.Open, FileAccess.Read))
    using (StreamReader sr = new StreamReader(fs)) markdown = await sr.ReadToEndAsync();

    if (string.IsNullOrEmpty(markdown))
        return NotFound();

    var model = ParseMarkdownToModel(markdown);
    model.RelativePath = relativePath;
    model.PhysicalPath = pageFile;

    if (folderConfig != null)
    {
        model.FolderConfiguration = folderConfig;
        folderConfig.PreProcess?.Invoke(model, this);
        return View(folderConfig.ViewTemplate, model);
    }

    return View(MarkdownConfiguration.DefaultMarkdownViewTemplate, model);
}

Код начинается с получения переменных Context, которые были установлены в middleware при пересылке запроса, проверки файла и, если он найден, чтения его с диска и рендеринга в HTML. Модель создается и строится с важным тайтлом и отрендеренным HTML, которые используются непосредственно представлением, наряду с некоторой информацией о файле и информацией о конфигурации, которые могут использоваться либо внутри представления, либо в средстве предварительной обработки. Когда все сказано и сделано, модель отправляется в представление для рендеринга.

Расширения

После того, как вы создали middleware-компонент, его еще нужно подключить и добавить в конвейер. ASP.NET Core предоставляет универсальные функции для добавления типизированного middleware, но эти функции нелегко обнаружить. Лучший способ — сделать то, что делают нативные middleware, то есть предоставить методы расширения, расширяющие IServiceCollection и IApplicationBuilder. В листинге 5 показан код расширения.

Листинг 5: Конфигурирование и подключение middleware к конвейеру

public static class MarkdownMiddlewareExtensions
{

public static IServiceCollection AddMarkdown(
        this IServiceCollection services,
        Action<MarkdownConfiguration> configAction = null)
{
    var config = new MarkdownConfiguration();

    configAction?.Invoke(config);

    if (config.ConfigureMarkdigPipeline != null)
        MarkdownParserMarkdig.ConfigurePipelineBuilder = config.ConfigureMarkdigPipeline;

    config.MarkdownProcessingFolders = config.MarkdownProcessingFolders
        .OrderBy(f => f.RelativePath)
        .ToList();

    services.AddSingleton(config);

    return services;
}

public static IApplicationBuilder UseMarkdown(this IApplicationBuilder builder)
{
    return builder.UseMiddleware<MarkdownPageProcessorMiddleware>();
} }

Метод AddMarkdown() предоставляет конфигурацию службы, создавая дефолтный объект конфигурации, а затем используя опциональный Action<MarkdownConfiguration>(), который мы предоставляем для конфигурирования middleware. Это распространенный шаблон, который вызывает конфигурацию с задержкой при поступлении первого запроса. Последним действием метода является явное добавление конфигурации в контейнер внедрения зависимостей, чтобы ее можно было получить в middleware и контроллере посредством внедрения зависимостей.

UseMarkdown() очень прост и просто делегирует builder.UseMiddleware<MarkdownPageProcessor>(). Единственная цель метода-оболочки — предоставить обнаруживаемый метод в Startup.Configure(), который согласуется с тем, как ведет себя встроенное middleware ASP.NET Core.

И вуаля!

Теперь у нас есть полностью реюзабельный механизм рендеринга Markdown-страниц, который можно легко подключить к любому приложению ASP.NET Core с помощью нескольких строк конфигурационного кода. С этого момента я могу просто поместить Markdown-файлы и связанные с ними ресурсы в свою папку wwwroot.

И на этом все. Markdown вам в помощь!


Приглашаем всех желающих на открытое занятие «Создание консольного калькулятора». На занятии сделаем с нуля консольный калькулятор, используя такие понятия как: ветвления, функции и циклы в C#. Регистрируйтесь по ссылке.

Комментарии (0)