forked from robur/blog.robur.coop
681 lines
20 KiB
OCaml
681 lines
20 KiB
OCaml
open Yocaml
|
|
|
|
module SM = Map.Make(String)
|
|
|
|
let is_empty_list = function [] -> true | _ -> false
|
|
|
|
module Date = struct
|
|
type month =
|
|
| Jan
|
|
| Feb
|
|
| Mar
|
|
| Apr
|
|
| May
|
|
| Jun
|
|
| Jul
|
|
| Aug
|
|
| Sep
|
|
| Oct
|
|
| Nov
|
|
| Dec
|
|
|
|
type day_of_week = Mon | Tue | Wed | Thu | Fri | Sat | Sun
|
|
type year = int
|
|
type day = int
|
|
type hour = int
|
|
type min = int
|
|
type sec = int
|
|
|
|
type t = {
|
|
year : year
|
|
; month : month
|
|
; day : day
|
|
; hour : hour
|
|
; min : min
|
|
; sec : sec
|
|
}
|
|
|
|
let invalid_int x message =
|
|
Data.Validation.fail_with ~given:(string_of_int x) message
|
|
|
|
let month_from_int x =
|
|
if x > 0 && x <= 12 then
|
|
Result.ok
|
|
[| Jan; Feb; Mar; Apr; May; Jun; Jul; Aug; Sep; Oct; Nov; Dec |].(x - 1)
|
|
else invalid_int x "Invalid month value"
|
|
|
|
let year_from_int x =
|
|
if x >= 0 then Result.ok x else invalid_int x "Invalid year value"
|
|
|
|
let is_leap year =
|
|
if year mod 100 = 0 then year mod 400 = 0 else year mod 4 = 0
|
|
|
|
let days_in_month year month =
|
|
match month with
|
|
| Jan | Mar | May | Jul | Aug | Oct | Dec -> 31
|
|
| Feb -> if is_leap year then 29 else 28
|
|
| _ -> 30
|
|
|
|
let day_from_int year month x =
|
|
let dim = days_in_month year month in
|
|
if x >= 1 && x <= dim then Result.ok x
|
|
else invalid_int x "Invalid day value"
|
|
|
|
let hour_from_int x =
|
|
if x >= 0 && x < 24 then Result.ok x else invalid_int x "Invalid hour value"
|
|
|
|
let min_from_int x =
|
|
if x >= 0 && x < 60 then Result.ok x else invalid_int x "Invalid min value"
|
|
|
|
let sec_from_int x =
|
|
if x >= 0 && x < 60 then Result.ok x else invalid_int x "Invalid sec value"
|
|
|
|
let ( let* ) = Result.bind
|
|
|
|
let make ?(time = (0, 0, 0)) ~year ~month ~day () =
|
|
let hour, min, sec = time in
|
|
let* year = year_from_int year in
|
|
let* month = month_from_int month in
|
|
let* day = day_from_int year month day in
|
|
let* hour = hour_from_int hour in
|
|
let* min = min_from_int min in
|
|
let* sec = sec_from_int sec in
|
|
Result.ok { year; month; day; hour; min; sec }
|
|
|
|
let validate_from_datetime_str str =
|
|
let str = String.trim str in
|
|
match
|
|
Scanf.sscanf_opt str "%04d%c%02d%c%02d%c%02d%c%02d%c%02d"
|
|
(fun year _ month _ day _ hour _ min _ sec ->
|
|
((hour, min, sec), year, month, day))
|
|
with
|
|
| None -> Data.Validation.fail_with ~given:str "Invalid date format"
|
|
| Some (time, year, month, day) -> make ~time ~year ~month ~day ()
|
|
|
|
let validate_from_date_str str =
|
|
let str = String.trim str in
|
|
match
|
|
Scanf.sscanf_opt str "%04d%c%02d%c%02d" (fun year _ month _ day ->
|
|
(year, month, day))
|
|
with
|
|
| None -> Data.Validation.fail_with ~given:str "Invalid date format"
|
|
| Some (year, month, day) -> make ~year ~month ~day ()
|
|
|
|
let validate =
|
|
let open Data.Validation in
|
|
string & (validate_from_datetime_str / validate_from_date_str)
|
|
|
|
let month_to_int = function
|
|
| Jan -> 1
|
|
| Feb -> 2
|
|
| Mar -> 3
|
|
| Apr -> 4
|
|
| May -> 5
|
|
| Jun -> 6
|
|
| Jul -> 7
|
|
| Aug -> 8
|
|
| Sep -> 9
|
|
| Oct -> 10
|
|
| Nov -> 11
|
|
| Dec -> 12
|
|
|
|
let dow_to_int = function
|
|
| Mon -> 0
|
|
| Tue -> 1
|
|
| Wed -> 2
|
|
| Thu -> 3
|
|
| Fri -> 4
|
|
| Sat -> 5
|
|
| Sun -> 6
|
|
|
|
let compare_date a b =
|
|
let cmp = Int.compare a.year b.year in
|
|
if Int.equal cmp 0 then
|
|
let cmp = Int.compare (month_to_int a.month) (month_to_int b.month) in
|
|
if Int.equal cmp 0 then Int.compare a.day b.day else cmp
|
|
else cmp
|
|
|
|
let compare_time a b =
|
|
let cmp = Int.compare a.hour b.hour in
|
|
if Int.equal cmp 0 then
|
|
let cmp = Int.compare a.min b.min in
|
|
if Int.equal cmp 0 then Int.compare a.sec b.sec else cmp
|
|
else cmp
|
|
|
|
let compare a b =
|
|
let cmp = compare_date a b in
|
|
if Int.equal cmp 0 then compare_time a b else cmp
|
|
|
|
let pp_date ppf { year; month; day; _ } =
|
|
Format.fprintf ppf "%04d-%02d-%02d" year (month_to_int month) day
|
|
|
|
let month_value = function
|
|
| Jan -> 0
|
|
| Feb -> 3
|
|
| Mar -> 3
|
|
| Apr -> 6
|
|
| May -> 1
|
|
| Jun -> 4
|
|
| Jul -> 6
|
|
| Aug -> 2
|
|
| Sep -> 5
|
|
| Oct -> 0
|
|
| Nov -> 3
|
|
| Dec -> 5
|
|
|
|
let day_of_week { year; month; day; _ } =
|
|
let yy = year mod 100 in
|
|
let cc = (year - yy) / 100 in
|
|
let c_code = [| 6; 4; 2; 0 |].(cc mod 4) in
|
|
let y_code = (yy + (yy / 4)) mod 7 in
|
|
let m_code =
|
|
let v = month_value month in
|
|
if is_leap year && (month = Jan || month = Feb) then v - 1 else v
|
|
in
|
|
let index = (c_code + y_code + m_code + day) mod 7 in
|
|
[| Sun; Mon; Tue; Wed; Thu; Fri; Sat |].(index)
|
|
|
|
let normalize ({ year; month; day; hour; min; sec } as dt) =
|
|
let day_of_week = day_of_week dt in
|
|
let open Data in
|
|
record
|
|
[
|
|
("year", int year); ("month", int (month_to_int month)); ("day", int day)
|
|
; ("hour", int hour); ("min", int min); ("sec", int sec)
|
|
; ("day_of_week", int (dow_to_int day_of_week))
|
|
; ("human", string (Format.asprintf "%a" pp_date dt))
|
|
]
|
|
|
|
let to_archetype_date_time { year; month; day; hour; min; sec } =
|
|
let time = (hour, min, sec) in
|
|
let month = month_to_int month in
|
|
Result.get_ok (Archetype.Datetime.make ~time ~year ~month ~day ())
|
|
end
|
|
|
|
module Page = struct
|
|
let entity_name = "Page"
|
|
|
|
class type t = object ('self)
|
|
method title : string option
|
|
method charset : string option
|
|
method description : string option
|
|
method tags : string list
|
|
method with_host : string -> 'self
|
|
method get_host : string option
|
|
end
|
|
|
|
class page ?title ?description ?charset ?(tags = []) () =
|
|
object (_ : #t)
|
|
method title = title
|
|
method charset = charset
|
|
method description = description
|
|
method tags = tags
|
|
val host = None
|
|
method with_host v = {< host = Some v >}
|
|
method get_host = host
|
|
end
|
|
|
|
let neutral = Result.ok @@ new page ()
|
|
|
|
let validate fields =
|
|
let open Data.Validation in
|
|
let+ title = optional fields "title" string
|
|
and+ description = optional fields "description" string
|
|
and+ charset = optional fields "charset" string
|
|
and+ tags = optional_or fields ~default:[] "tags" (list_of string) in
|
|
new page ?title ?description ?charset ~tags ()
|
|
|
|
let validate =
|
|
let open Data.Validation in
|
|
record validate
|
|
end
|
|
|
|
module Author = struct
|
|
class type t = object
|
|
method name : string
|
|
method link : string
|
|
method email : string
|
|
method avatar : string option
|
|
end
|
|
|
|
let gravatar email =
|
|
let tk = String.(lowercase_ascii (trim email)) in
|
|
let hs = Digest.(to_hex (string tk)) in
|
|
"https://www.gravatar.com/avatar/" ^ hs
|
|
|
|
class author ~name ~link ~email ?(avatar = gravatar email) () =
|
|
object (_ : #t)
|
|
method name = name
|
|
method link = link
|
|
method email = email
|
|
method avatar = Some avatar
|
|
end
|
|
|
|
let validate fields =
|
|
let open Data.Validation in
|
|
let+ name = required fields "name" string
|
|
and+ link = required fields "link" string
|
|
and+ email = required fields "email" string
|
|
and+ avatar = optional fields "avatar" string in
|
|
match avatar with
|
|
| None -> new author ~name ~link ~email ()
|
|
| Some avatar -> new author ~name ~link ~email ~avatar ()
|
|
|
|
let validate =
|
|
let open Data.Validation in
|
|
record validate
|
|
|
|
let normalize obj =
|
|
let open Data in
|
|
record
|
|
[
|
|
("name", string obj#name); ("link", string obj#link)
|
|
; ("email", string obj#email); ("avatar", option string obj#avatar)
|
|
]
|
|
end
|
|
|
|
let robur_coop =
|
|
new Author.author
|
|
~name:"The Robur Team" ~link:"https://robur.coop/"
|
|
~email:"team@robur.coop" ()
|
|
|
|
module Article = struct
|
|
let entity_name = "Article"
|
|
|
|
class type t = object ('self)
|
|
method title : string
|
|
method description : string
|
|
method charset : string option
|
|
method tags : string list
|
|
method date : Date.t
|
|
method author : Author.t
|
|
method co_authors : Author.t list
|
|
method with_host : string -> 'self
|
|
method get_host : string option
|
|
end
|
|
|
|
class article ~title ~description ?charset ?(tags = []) ~date ~author
|
|
?(co_authors = []) () =
|
|
object (_ : #t)
|
|
method title = title
|
|
method description = description
|
|
method charset = charset
|
|
method tags = tags
|
|
method date = date
|
|
method author = author
|
|
method co_authors = co_authors
|
|
val host = None
|
|
method with_host v = {< host = Some v >}
|
|
method get_host = host
|
|
end
|
|
|
|
let title p = p#title
|
|
let description p = p#description
|
|
let date p = p#date
|
|
|
|
let neutral =
|
|
Data.Validation.fail_with ~given:"null" "Cannot be null"
|
|
|> Result.map_error (fun error ->
|
|
Required.Validation_error { entity = entity_name; error })
|
|
|
|
let validate fields =
|
|
let open Data.Validation in
|
|
let+ title = required fields "title" string
|
|
and+ description = required fields "description" string
|
|
and+ charset = optional fields "charset" string
|
|
and+ tags = optional_or fields ~default:[] "tags" (list_of string)
|
|
and+ date = required fields "date" Date.validate
|
|
and+ author =
|
|
optional_or fields ~default:robur_coop "author" Author.validate
|
|
and+ co_authors =
|
|
optional_or fields ~default:[] "co-authors" (list_of Author.validate)
|
|
in
|
|
new article ~title ~description ?charset ~tags ~date ~author ~co_authors ()
|
|
|
|
let validate =
|
|
let open Data.Validation in
|
|
record validate
|
|
|
|
let normalize obj =
|
|
Data.
|
|
[
|
|
("title", string obj#title); ("description", string obj#description)
|
|
; ("date", Date.normalize obj#date); ("charset", option string obj#charset)
|
|
; ("tags", list_of string obj#tags)
|
|
; ("author", Author.normalize obj#author)
|
|
; ("co-authors", list_of Author.normalize obj#co_authors)
|
|
; ("host", option string obj#get_host)
|
|
]
|
|
end
|
|
|
|
module Articles = struct
|
|
class type t = object ('self)
|
|
method title : string option
|
|
method description : string option
|
|
method articles : (Path.t * Article.t) list
|
|
method with_host : string -> 'self
|
|
method get_host : string option
|
|
end
|
|
|
|
class articles ?title ?description articles =
|
|
object (_ : #t)
|
|
method title = title
|
|
method description = description
|
|
method articles = articles
|
|
val host = None
|
|
method with_host v = {< host = Some v >}
|
|
method get_host = host
|
|
end
|
|
|
|
let sort_by_date ?(increasing = false) articles =
|
|
List.sort
|
|
(fun (_, articleA) (_, articleB) ->
|
|
let r = Date.compare articleA#date articleB#date in
|
|
if increasing then r else ~-r)
|
|
articles
|
|
|
|
let fetch (module P : Required.DATA_PROVIDER) ?increasing
|
|
?(filter = fun x -> x) ?(on = `Source) ~where ~compute_link path =
|
|
Task.from_effect begin fun () ->
|
|
let open Eff in
|
|
let* files = read_directory ~on ~only:`Files ~where path in
|
|
let+ articles =
|
|
List.traverse
|
|
(fun file ->
|
|
let url = compute_link file in
|
|
let+ metadata, _content =
|
|
Eff.read_file_with_metadata (module P) (module Article) ~on file
|
|
in
|
|
(url, metadata))
|
|
files
|
|
in
|
|
articles |> sort_by_date ?increasing |> filter end
|
|
|
|
let compute_index (module P : Required.DATA_PROVIDER) ?increasing
|
|
?(filter = fun x -> x) ?(on = `Source) ~where ~compute_link path =
|
|
let open Task in
|
|
(fun x -> (x, ()))
|
|
|>> second
|
|
(fetch (module P) ?increasing ~filter ~on ~where ~compute_link path)
|
|
>>> lift (fun (v, articles) ->
|
|
new articles ?title:v#title ?description:v#description articles)
|
|
|
|
let normalize (ident, article) =
|
|
let open Data in
|
|
record (("url", string @@ Path.to_string ident) :: Article.normalize article)
|
|
|
|
let normalize obj =
|
|
let open Data in
|
|
[
|
|
("articles", list_of normalize obj#articles)
|
|
; ("has_articles", bool @@ is_empty_list obj#articles)
|
|
; ("title", option string obj#title)
|
|
; ("description", option string obj#description)
|
|
; ("host", option string obj#get_host)
|
|
]
|
|
end
|
|
|
|
module Tag = struct
|
|
type t = {
|
|
name : string;
|
|
articles : (Path.t * Article.t) list;
|
|
}
|
|
|
|
let make ~name ~articles =
|
|
{ name; articles }
|
|
|
|
let normalize_article (ident, article) =
|
|
let open Data in
|
|
record (("url", string @@ Path.to_string ident) :: Article.normalize article)
|
|
|
|
let normalize { name; articles } =
|
|
let open Data in
|
|
[
|
|
("name", string name);
|
|
("articles", (list_of normalize_article) articles);
|
|
]
|
|
end
|
|
|
|
module Tags = struct
|
|
class type t = object ('self)
|
|
inherit Articles.t
|
|
method tags : Tag.t list
|
|
end
|
|
|
|
class tags ?title ?description articles =
|
|
object
|
|
inherit Articles.articles ?title ?description articles as super
|
|
method! title = Some "Tags"
|
|
method tags =
|
|
let tags =
|
|
let update article sm tag =
|
|
SM.update tag
|
|
(function
|
|
| None -> Some [article]
|
|
| Some urls -> Some (article :: urls))
|
|
sm
|
|
in
|
|
List.fold_left
|
|
(fun sm (url, article) ->
|
|
List.fold_left (update (url, article)) sm article#tags)
|
|
SM.empty
|
|
super#articles
|
|
|> SM.bindings
|
|
in
|
|
List.map (fun (tag, articles) ->
|
|
Tag.make ~name:tag ~articles)
|
|
tags
|
|
end
|
|
|
|
let of_articles articles =
|
|
new tags ?title:articles#title ?description:articles#description articles#articles
|
|
|
|
let normalize_tag tag =
|
|
let open Data in
|
|
record (Tag.normalize tag)
|
|
|
|
let normalize tags =
|
|
let open Data in
|
|
("all_tags", (list_of normalize_tag tags#tags)) :: Articles.normalize tags
|
|
end
|
|
|
|
module Make_with_target (S : sig
|
|
val source : Path.t
|
|
val target : Path.t
|
|
end) =
|
|
struct
|
|
let source_root = S.source
|
|
|
|
module Source = struct
|
|
let css = Path.(source_root / "css")
|
|
let js = Path.(source_root / "js")
|
|
let images = Path.(source_root / "images")
|
|
let articles = Path.(source_root / "articles")
|
|
let index = Path.(source_root / "pages" / "index.md")
|
|
let tags = Path.(source_root / "pages" / "tags.md")
|
|
let templates = Path.(source_root / "templates")
|
|
let template file = Path.(templates / file)
|
|
let binary = Path.rel [ Sys.argv.(0) ]
|
|
let cache = Path.(source_root / "_cache")
|
|
end
|
|
|
|
module Target = struct
|
|
let target_root = S.target
|
|
let pages = target_root
|
|
let articles = Path.(target_root / "articles")
|
|
let rss2 = Path.(target_root / "feed.xml")
|
|
|
|
let as_html into file =
|
|
file |> Path.move ~into |> Path.change_extension "html"
|
|
end
|
|
|
|
let target = Target.target_root
|
|
|
|
let process_css_files =
|
|
Action.copy_directory ~into:Target.target_root Source.css
|
|
|
|
let process_js_files =
|
|
Action.copy_directory ~into:Target.target_root Source.js
|
|
|
|
let process_images_files =
|
|
Action.copy_directory ~into:Target.target_root Source.images
|
|
|
|
let process_article ~host file =
|
|
let file_target = Target.(as_html articles file) in
|
|
let open Task in
|
|
Action.write_static_file file_target
|
|
begin
|
|
Pipeline.track_file Source.binary
|
|
>>> Yocaml_yaml.Pipeline.read_file_with_metadata (module Article) file
|
|
>>* (fun (obj, str) -> Eff.return (obj#with_host host, str))
|
|
>>> Yocaml_cmarkit.content_to_html ~strict:false ()
|
|
>>> Yocaml_jingoo.Pipeline.as_template
|
|
(module Article)
|
|
(Source.template "article.html")
|
|
>>> Yocaml_jingoo.Pipeline.as_template
|
|
(module Article)
|
|
(Source.template "layout.html")
|
|
>>> drop_first ()
|
|
end
|
|
|
|
let process_articles ~host =
|
|
Action.batch ~only:`Files ~where:(Path.has_extension "md") Source.articles
|
|
(process_article ~host)
|
|
|
|
let process_index ~host =
|
|
let file = Source.index in
|
|
let file_target = Target.(as_html pages file) in
|
|
|
|
let open Task in
|
|
let compute_index =
|
|
Articles.compute_index
|
|
(module Yocaml_yaml)
|
|
~where:(Path.has_extension "md")
|
|
~compute_link:(Target.as_html @@ Path.abs [ "articles" ])
|
|
Source.articles
|
|
in
|
|
|
|
Action.write_static_file file_target
|
|
begin
|
|
Pipeline.track_files [ Source.binary; Source.articles ]
|
|
>>> Yocaml_yaml.Pipeline.read_file_with_metadata (module Page) file
|
|
>>> Yocaml_cmarkit.content_to_html ~strict:false ()
|
|
>>> first compute_index
|
|
>>* (fun (obj, str) -> Eff.return (obj#with_host host, str))
|
|
>>> Yocaml_jingoo.Pipeline.as_template ~strict:true
|
|
(module Articles)
|
|
(Source.template "index.html")
|
|
>>> Yocaml_jingoo.Pipeline.as_template ~strict:true
|
|
(module Articles)
|
|
(Source.template "layout.html")
|
|
>>> drop_first ()
|
|
end
|
|
|
|
let process_tags ~host =
|
|
let file = Source.tags in
|
|
let file_target = Target.(as_html pages file) in
|
|
|
|
let open Task in
|
|
let compute_index =
|
|
Articles.compute_index
|
|
(module Yocaml_yaml)
|
|
~where:(Path.has_extension "md")
|
|
~compute_link:(Target.as_html @@ Path.abs [ "articles" ])
|
|
Source.articles
|
|
in
|
|
|
|
Action.write_static_file file_target
|
|
begin
|
|
Pipeline.track_files [ Source.binary; Source.articles ]
|
|
>>> Yocaml_yaml.Pipeline.read_file_with_metadata (module Page) file
|
|
>>> Yocaml_cmarkit.content_to_html ~strict:false ()
|
|
>>> first compute_index
|
|
>>* (fun (obj, str) -> Eff.return (Tags.of_articles (obj#with_host host), str))
|
|
>>> Yocaml_jingoo.Pipeline.as_template ~strict:true
|
|
(module Tags)
|
|
(Source.template "tags.html")
|
|
>>> Yocaml_jingoo.Pipeline.as_template ~strict:true
|
|
(module Tags)
|
|
(Source.template "layout.html")
|
|
>>> drop_first ()
|
|
end
|
|
|
|
let feed_title = "The Robur's blog"
|
|
let site_url = "https://blog.robur.coop"
|
|
let feed_description = "The Robur cooperative blog"
|
|
|
|
let fetch_articles =
|
|
let open Task in
|
|
Pipeline.track_files [ Source.binary; Source.articles ]
|
|
>>> Articles.fetch
|
|
(module Yocaml_yaml)
|
|
~where:(Path.has_extension "md")
|
|
~compute_link:(Target.as_html @@ Path.abs [ "articles" ])
|
|
Source.articles
|
|
|
|
let rss2 =
|
|
let open Task in
|
|
let from_articles ~title ~site_url ~description ~feed_url () =
|
|
let open Yocaml_syndication in
|
|
lift
|
|
begin
|
|
fun articles ->
|
|
let last_build_date =
|
|
List.fold_left
|
|
begin
|
|
fun acc (_, elt) ->
|
|
let v = Date.to_archetype_date_time (Article.date elt) in
|
|
match acc with
|
|
| None -> Some v
|
|
| Some a ->
|
|
if Archetype.Datetime.compare a v > 0 then Some a
|
|
else Some v
|
|
end
|
|
None articles
|
|
|> Option.map Datetime.make
|
|
in
|
|
let feed =
|
|
Rss2.feed ?last_build_date ~title ~link:site_url ~url:feed_url
|
|
~description
|
|
begin
|
|
fun (path, article) ->
|
|
let title = Article.title article in
|
|
let link = site_url ^ Path.to_string path in
|
|
let guid = Rss2.guid_from_link in
|
|
let description = Article.description article in
|
|
let pub_date =
|
|
Datetime.make
|
|
(Date.to_archetype_date_time (Article.date article))
|
|
in
|
|
Rss2.item ~title ~link ~guid ~description ~pub_date ()
|
|
end
|
|
articles
|
|
in
|
|
Xml.to_string feed
|
|
end
|
|
in
|
|
Action.write_static_file Target.rss2
|
|
begin
|
|
fetch_articles
|
|
>>> from_articles ~title:feed_title ~site_url
|
|
~description:feed_description
|
|
~feed_url:"https://blog.robur.coop/feed.xml" ()
|
|
end
|
|
|
|
let process_all ~host =
|
|
let open Eff in
|
|
Action.restore_cache ~on:`Source Source.cache
|
|
>>= process_css_files >>= process_js_files >>= process_images_files
|
|
>>= process_tags ~host
|
|
>>= process_articles ~host >>= process_index ~host >>= rss2
|
|
>>= Action.store_cache ~on:`Source Source.cache
|
|
end
|
|
|
|
module Make (S : sig
|
|
val source : Path.t
|
|
end) =
|
|
Make_with_target (struct
|
|
include S
|
|
|
|
let target = Path.(source / "_site")
|
|
end)
|