WIP on DotLiquid support

This commit is contained in:
Daniel J. Summers 2022-04-16 23:06:38 -04:00
parent 62f7896621
commit 98eb2b1785
78 changed files with 451 additions and 45 deletions

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -60,6 +60,7 @@ open Microsoft.FSharpLu.Json
/// All converters to use for data conversion
let all () : JsonConverter seq =
seq {
// Our converters
CategoryIdConverter ()
CommentIdConverter ()
PermalinkConverter ()

View File

@ -44,10 +44,18 @@ module Helpers =
match! f conn with Some it when (prop it) = webLogId -> return Some it | _ -> return None
}
/// Get the first item from a list, or None if the list is empty
let tryFirst<'T> (f : IConnection -> Task<'T list>) =
fun conn -> task {
let! results = f conn
return results |> List.tryHead
}
open RethinkDb.Driver.FSharp
open Microsoft.Extensions.Logging
/// Start up checks to ensure the database, tables, and indexes exist
module Startup =
/// Ensure field indexes exist, as well as special indexes for selected tables
@ -151,6 +159,16 @@ module Category =
/// Functions to manipulate pages
module Page =
/// Add a new page
let add (page : Page) =
rethink {
withTable Table.Page
insert page
write
withRetryDefault
ignoreResult
}
/// Count all pages for a web log
let countAll (webLogId : WebLogId) =
rethink<int> {
@ -195,14 +213,15 @@ module Page =
/// Find a page by its permalink
let findByPermalink (permalink : Permalink) (webLogId : WebLogId) =
rethink<Page> {
rethink<Page list> {
withTable Table.Page
getAll [ r.Array (webLogId, permalink) ] (nameof permalink)
without [ "priorPermalinks", "revisions" ]
limit 1
resultOption
result
withRetryDefault
}
|> tryFirst
/// Find a page by its ID (including permalinks and revisions)
let findByFullId (pageId : PageId) webLogId =
@ -243,14 +262,15 @@ module Post =
/// Find a post by its permalink
let findByPermalink (permalink : Permalink) (webLogId : WebLogId) =
rethink<Post> {
rethink<Post list> {
withTable Table.Post
getAll [ r.Array(permalink, webLogId) ] (nameof permalink)
without [ "priorPermalinks", "revisions" ]
limit 1
resultOption
result
withRetryDefault
}
|> tryFirst
/// Find posts to be displayed on a page
let findPageOfPublishedPosts (webLogId : WebLogId) pageNbr postsPerPage =
@ -270,15 +290,26 @@ module Post =
/// Functions to manipulate web logs
module WebLog =
/// Add a web log
let add (webLog : WebLog) =
rethink {
withTable Table.WebLog
insert webLog
write
withRetryOnce
ignoreResult
}
/// Retrieve web log details by the URL base
let findByHost (url : string) =
rethink<WebLog> {
rethink<WebLog list> {
withTable Table.WebLog
getAll [ url ] "urlBase"
limit 1
resultOption
result
withRetryDefault
}
|> tryFirst
/// Update web log settings
let updateSettings (webLog : WebLog) =
@ -296,3 +327,18 @@ module WebLog =
withRetryDefault
ignoreResult
}
/// Functions to manipulate web log users
module WebLogUser =
/// Add a web log user
let add (user : WebLogUser) =
rethink {
withTable Table.WebLogUser
insert user
write
withRetryDefault
ignoreResult
}

View File

@ -248,7 +248,7 @@ module WebLog =
subtitle = None
defaultPage = ""
postsPerPage = 10
themePath = "Default"
themePath = "default"
urlBase = ""
timeZone = ""
}

View File

@ -8,6 +8,7 @@
<ItemGroup>
<Compile Include="SupportTypes.fs" />
<Compile Include="DataTypes.fs" />
<Compile Include="ViewModels.fs" />
</ItemGroup>
</Project>

View File

@ -9,7 +9,7 @@ module private Helpers =
/// Create a new ID (short GUID)
// https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID
let newId() =
Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace('/', '_').Replace('+', '-')[..22]
Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace('/', '_').Replace('+', '-').Substring (0, 22)
/// An identifier for a category

View File

@ -0,0 +1,32 @@
namespace MyWebLog.ViewModels
open MyWebLog
/// Base model class for myWebLog views
type MyWebLogModel (webLog : WebLog) =
/// The details for the web log
member val WebLog = webLog with get
/// The model to use to allow a user to log on
[<CLIMutable>]
type LogOnModel =
{ /// The user's e-mail address
emailAddress : string
/// The user's password
password : string
}
/// The model used to render a single page
type SinglePageModel =
{ /// The page to be rendered
page : Page
/// The web log to which the page belongs
webLog : WebLog
}
/// Is this the home page?
member this.isHome with get () = PageId.toString this.page.id = this.webLog.defaultPage

View File

@ -20,7 +20,6 @@ type AdminController () =
let! posts = Data.Post.countByStatus Published |> getCount
let! drafts = Data.Post.countByStatus Draft |> getCount
let! pages = Data.Page.countAll |> getCount
let! pages = Data.Page.countAll |> getCount
let! listed = Data.Page.countListed |> getCount
let! cats = Data.Category.countAll |> getCount
let! topCats = Data.Category.countTopLevel |> getCount

View File

@ -0,0 +1,2 @@
module MyWebLog.Handlers

View File

@ -5,6 +5,7 @@
</PropertyGroup>
<ItemGroup>
<Compile Include="Handlers.fs" />
<Compile Include="WebLogCache.fs" />
<Compile Include="Features\Shared\SharedTypes.fs" />
<Compile Include="Features\Admin\AdminTypes.fs" />

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -20,13 +20,6 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyWebLog\MyWebLog.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
</Project>

View File

@ -3,17 +3,19 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.1.32210.238
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog", "MyWebLog\MyWebLog.csproj", "{3139DA09-C999-465A-BC98-02FEC3BD7E88}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.Themes.BitBadger", "MyWebLog.Themes.BitBadger\MyWebLog.Themes.BitBadger.csproj", "{729F7AB3-2300-4390-B972-71D32FBBBF50}"
EndProject
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.FS", "MyWebLog.FS\MyWebLog.FS.fsproj", "{4D62F235-73BA-42A6-8AA1-29D0D046E115}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyWebLog.Domain", "MyWebLog.Domain\MyWebLog.Domain.fsproj", "{8CA99122-888A-4524-8C1B-685F0A4B7B4B}"
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.Domain", "MyWebLog.Domain\MyWebLog.Domain.fsproj", "{8CA99122-888A-4524-8C1B-685F0A4B7B4B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.DataCS", "MyWebLog.DataCS\MyWebLog.DataCS.csproj", "{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.fsproj", "{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}"
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.fsproj", "{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.CS", "MyWebLog.CS\MyWebLog.CS.csproj", "{B23A8093-28B1-4CB5-93F1-B4659516B74F}"
EndProject
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.FS.Old", "MyWebLog.FS.Old\MyWebLog.FS.Old.fsproj", "{C0AD7194-572E-4112-87C4-5235987C90C1}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyWebLog", "MyWebLog\MyWebLog.fsproj", "{5655B63D-429F-4CCD-A14C-FBD74D987ECB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -21,18 +23,10 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Release|Any CPU.Build.0 = Release|Any CPU
{729F7AB3-2300-4390-B972-71D32FBBBF50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{729F7AB3-2300-4390-B972-71D32FBBBF50}.Debug|Any CPU.Build.0 = Debug|Any CPU
{729F7AB3-2300-4390-B972-71D32FBBBF50}.Release|Any CPU.ActiveCfg = Release|Any CPU
{729F7AB3-2300-4390-B972-71D32FBBBF50}.Release|Any CPU.Build.0 = Release|Any CPU
{4D62F235-73BA-42A6-8AA1-29D0D046E115}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4D62F235-73BA-42A6-8AA1-29D0D046E115}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4D62F235-73BA-42A6-8AA1-29D0D046E115}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4D62F235-73BA-42A6-8AA1-29D0D046E115}.Release|Any CPU.Build.0 = Release|Any CPU
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -45,6 +39,18 @@ Global
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Release|Any CPU.Build.0 = Release|Any CPU
{B23A8093-28B1-4CB5-93F1-B4659516B74F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B23A8093-28B1-4CB5-93F1-B4659516B74F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B23A8093-28B1-4CB5-93F1-B4659516B74F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B23A8093-28B1-4CB5-93F1-B4659516B74F}.Release|Any CPU.Build.0 = Release|Any CPU
{C0AD7194-572E-4112-87C4-5235987C90C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C0AD7194-572E-4112-87C4-5235987C90C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C0AD7194-572E-4112-87C4-5235987C90C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C0AD7194-572E-4112-87C4-5235987C90C1}.Release|Any CPU.Build.0 = Release|Any CPU
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

59
src/MyWebLog/Handlers.fs Normal file
View File

@ -0,0 +1,59 @@
[<RequireQualifiedAccess>]
module MyWebLog.Handlers
open Giraffe
open MyWebLog
open MyWebLog.ViewModels
open System
[<AutoOpen>]
module private Helpers =
open DotLiquid
open System.Collections.Concurrent
open System.IO
/// Cache for parsed templates
let private themeViews = ConcurrentDictionary<string, Template> ()
/// Return a view for a theme
let themedView<'T> (template : string) (model : obj) : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx
let templatePath = $"themes/{webLog.themePath}/{template}"
match themeViews.ContainsKey templatePath with
| true -> ()
| false ->
let! file = File.ReadAllTextAsync $"{templatePath}.liquid"
themeViews[templatePath] <- Template.Parse file
let view = themeViews[templatePath].Render (Hash.FromAnonymousObject model)
return! htmlString view next ctx
}
module User =
open System.Security.Cryptography
open System.Text
/// Hash a password for a given user
let hashedPassword (plainText : string) (email : string) (salt : Guid) =
let allSalt = Array.concat [ salt.ToByteArray(); (Encoding.UTF8.GetBytes email) ]
use alg = new Rfc2898DeriveBytes (plainText, allSalt, 2_048)
Convert.ToBase64String(alg.GetBytes(64))
module CatchAll =
let catchAll : HttpHandler = fun next ctx -> task {
let testPage = { Page.empty with text = "Howdy, folks!" }
return! themedView "single-page" { page = testPage; webLog = WebLogCache.getByCtx ctx } next ctx
}
open Giraffe.EndpointRouting
/// The endpoints defined in the above handlers
let endpoints = [
GET [
route "" CatchAll.catchAll
]
]

View File

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Content Include="appsettings.json" CopyToOutputDirectory="Always" />
<Compile Include="WebLogCache.fs" />
<Compile Include="Handlers.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DotLiquid" Version="2.2.610" />
<PackageReference Include="Giraffe" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyWebLog.Data\MyWebLog.Data.fsproj" />
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
</ItemGroup>
<ItemGroup>
<None Include=".\themes\**" CopyToOutputDirectory="Always" />
<None Include=".\wwwroot\**" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup />
</Project>

150
src/MyWebLog/Program.fs Normal file
View File

@ -0,0 +1,150 @@
open Giraffe.EndpointRouting
open Microsoft.AspNetCore.Authentication.Cookies
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging
open MyWebLog
open RethinkDb.Driver.FSharp
open RethinkDb.Driver.Net
open System
/// Middleware to derive the current web log
type WebLogMiddleware (next : RequestDelegate) =
member this.InvokeAsync (ctx : HttpContext) = task {
let host = ctx.Request.Host.ToUriComponent ()
match WebLogCache.exists host with
| true -> return! next.Invoke ctx
| false ->
let conn = ctx.RequestServices.GetRequiredService<IConnection> ()
match! Data.WebLog.findByHost host conn with
| Some webLog ->
WebLogCache.set host webLog
return! next.Invoke ctx
| None -> ctx.Response.StatusCode <- 404
}
/// Initialize a new database
let initDbValidated (args : string[]) (sp : IServiceProvider) = task {
let conn = sp.GetRequiredService<IConnection> ()
let timeZone =
let local = TimeZoneInfo.Local.Id
match TimeZoneInfo.Local.HasIanaId with
| true -> local
| false ->
match TimeZoneInfo.TryConvertWindowsIdToIanaId local with
| true, ianaId -> ianaId
| false, _ -> raise <| TimeZoneNotFoundException $"Cannot find IANA timezone for {local}"
// Create the web log
let webLogId = WebLogId.create ()
let userId = WebLogUserId.create ()
let homePageId = PageId.create ()
do! Data.WebLog.add
{ WebLog.empty with
id = webLogId
name = args[2]
urlBase = args[1]
defaultPage = PageId.toString homePageId
timeZone = timeZone
} conn
// Create the admin user
let salt = Guid.NewGuid ()
do! Data.WebLogUser.add
{ WebLogUser.empty with
id = userId
webLogId = webLogId
userName = args[3]
firstName = "Admin"
lastName = "User"
preferredName = "Admin"
passwordHash = Handlers.User.hashedPassword args[4] args[3] salt
salt = salt
authorizationLevel = Administrator
} conn
// Create the default home page
do! Data.Page.add
{ Page.empty with
id = homePageId
webLogId = webLogId
authorId = userId
title = "Welcome to myWebLog!"
permalink = Permalink "welcome-to-myweblog.html"
publishedOn = DateTime.UtcNow
updatedOn = DateTime.UtcNow
text = "<p>This is your default home page.</p>"
revisions = [
{ asOf = DateTime.UtcNow
sourceType = Html
text = "<p>This is your default home page.</p>"
}
]
} conn
Console.WriteLine($"Successfully initialized database for {args[2]} with URL base {args[1]}");
}
/// Initialize a new database
let initDb args sp = task {
match args |> Array.length with
| 5 -> return! initDbValidated args sp
| _ ->
Console.WriteLine "Usage: MyWebLog init [url] [name] [admin-email] [admin-pw]"
return! System.Threading.Tasks.Task.CompletedTask
}
[<EntryPoint>]
let main args =
let builder = WebApplication.CreateBuilder(args)
let _ =
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(fun opts ->
opts.ExpireTimeSpan <- TimeSpan.FromMinutes 20.
opts.SlidingExpiration <- true
opts.AccessDeniedPath <- "/forbidden")
let _ = builder.Services.AddLogging ()
let _ = builder.Services.AddAuthorization()
// Configure RethinkDB's connection
JsonConverters.all () |> Seq.iter Converter.Serializer.Converters.Add
let sp = builder.Services.BuildServiceProvider ()
let config = sp.GetRequiredService<IConfiguration> ()
let loggerFac = sp.GetRequiredService<ILoggerFactory> ()
let rethinkCfg = DataConfig.FromConfiguration (config.GetSection "RethinkDB")
let conn =
task {
let! conn = rethinkCfg.CreateConnectionAsync ()
do! Data.Startup.ensureDb rethinkCfg (loggerFac.CreateLogger (nameof Data.Startup)) conn
return conn
} |> Async.AwaitTask |> Async.RunSynchronously
let _ = builder.Services.AddSingleton<IConnection> conn
let app = builder.Build ()
match args |> Array.tryHead with
| Some it when it = "init" -> initDb args app.Services |> Async.AwaitTask |> Async.RunSynchronously
| _ ->
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
let _ = app.UseMiddleware<WebLogMiddleware> ()
let _ = app.UseAuthentication ()
let _ = app.UseStaticFiles ()
let _ = app.UseRouting ()
let _ = app.UseEndpoints (fun endpoints -> endpoints.MapGiraffeEndpoints Handlers.endpoints)
app.Run()
0 // Exit code

View File

@ -0,0 +1,24 @@
/// <summary>
/// In-memory cache of web log details
/// </summary>
/// <remarks>This is filled by the middleware via the first request for each host, and can be updated via the web log
/// settings update page</remarks>
module MyWebLog.WebLogCache
open Microsoft.AspNetCore.Http
open System.Collections.Concurrent
/// The cache of web log details
let private _cache = ConcurrentDictionary<string, WebLog> ()
/// Does a host exist in the cache?
let exists host = _cache.ContainsKey host
/// Get the details for a web log via its host
let get host = _cache[host]
/// Get the details for a web log via its host
let getByCtx (ctx : HttpContext) = _cache[ctx.Request.Host.ToUriComponent ()]
/// Set the details for a particular host
let set host details = _cache[host] <- details

View File

@ -1,9 +1,6 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
{
"RethinkDB": {
"hostname": "data02.bitbadger.solutions",
"database": "myWebLog-dev"
}
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta name="generator" content="myWebLog 2">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link asp-theme="@Model.WebLog.ThemePath" />
<title>{{ title | escape }} &laquo; {{ web_log_name | escape }}</title>
</head>

View File

@ -0,0 +1,6 @@
<footer>
<hr>
<div class="container-fluid text-end">
<img src="/img/logo-dark.png" alt="myWebLog">
</div>
</footer>

View File

@ -0,0 +1,18 @@
<header>
<nav class="navbar navbar-light bg-light navbar-expand-md justify-content-start px-2">
<div class="container-fluid">
<a class="navbar-brand" href="~/">{{ web_log.name }}</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText"
aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarText">
{% if web_log.subtitle -%}
<span class="navbar-text">{{ web_log.subtitle | escape }}</span>
{%- endif %}
@* TODO: list pages for current web log *@
@await Html.PartialAsync("_LogOnOffPartial")
</div>
</div>
</nav>
</header>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
{{ render "_html-head", title: title, web_log_name: web_log.name }}
<body>
{{ render "_page-head", web_log: web_log }}
<main>
<h2>{{ page.title }}</h2>
<article>
{{ page.text }}
</article>
</main>
{{ render "_page-foot" }}
</body>
</html>

View File

@ -0,0 +1,5 @@
footer {
background-color: #808080;
border-top: solid 1px black;
color: white;
}