WIP mess
This commit is contained in:
commit
b025e693d9
52 changed files with 4662 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
*.beam
|
||||
*.ez
|
||||
build
|
||||
erl_crash.dump
|
||||
/data
|
||||
/static
|
||||
/output
|
2
.tool-versions
Normal file
2
.tool-versions
Normal file
|
@ -0,0 +1,2 @@
|
|||
nodejs 18.16.0
|
||||
gleam 0.30.2
|
24
README.md
Normal file
24
README.md
Normal file
|
@ -0,0 +1,24 @@
|
|||
# pilkahdus
|
||||
|
||||
[![Package Version](https://img.shields.io/hexpm/v/pilkahdus)](https://hex.pm/packages/pilkahdus)
|
||||
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/pilkahdus/)
|
||||
|
||||
A Gleam project
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
gleam run # Run the project
|
||||
gleam test # Run the tests
|
||||
gleam shell # Run an Erlang shell
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
If available on Hex this package can be added to your Gleam project:
|
||||
|
||||
```sh
|
||||
gleam add pilkahdus
|
||||
```
|
||||
|
||||
and its documentation can be found at <https://hexdocs.pm/pilkahdus>.
|
12
gleam.toml
Normal file
12
gleam.toml
Normal file
|
@ -0,0 +1,12 @@
|
|||
name = "gloss"
|
||||
version = "1.0.0"
|
||||
description = "A static blog generator"
|
||||
target = "javascript"
|
||||
gleam = ">= 0.30.0"
|
||||
|
||||
[dependencies]
|
||||
gleam_stdlib = "~> 0.30"
|
||||
glemplate = "~> 5.0"
|
||||
gleam_javascript = "~> 0.4"
|
||||
|
||||
[dev-dependencies]
|
15
manifest.toml
Normal file
15
manifest.toml
Normal file
|
@ -0,0 +1,15 @@
|
|||
# This file was generated by Gleam
|
||||
# You typically do not need to edit this file
|
||||
|
||||
packages = [
|
||||
{ name = "gleam_javascript", version = "0.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "E0E8D33461776BFCC124838183F85E430D3A71D7318F210C9DE0CFB52E5AC8DE" },
|
||||
{ name = "gleam_stdlib", version = "0.30.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "03710B3DA047A3683117591707FCA19D32B980229DD8CE8B0603EB5B5144F6C3" },
|
||||
{ name = "glemplate", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glentities", "nibble"], otp_app = "glemplate", source = "hex", outer_checksum = "48EB0FDD937B19460D8A095370515C1157632A8218539A77E73DDA208F193B46" },
|
||||
{ name = "glentities", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glentities", source = "hex", outer_checksum = "4D3AE91ECB088C853302643C4F256BEF7B8F8EC7650A7DA6B2ACAF4550113BE2" },
|
||||
{ name = "nibble", version = "0.2.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "nibble", source = "hex", outer_checksum = "C9EB428EAC11007414C2FBA5B39F0E8DB27FDAB26534F2AD607D5AA6FCF32559" },
|
||||
]
|
||||
|
||||
[requirements]
|
||||
gleam_javascript = { version = "~> 0.4" }
|
||||
gleam_stdlib = { version = "~> 0.30" }
|
||||
glemplate = { version = "~> 5.0" }
|
16
package.json
Normal file
16
package.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "gloss",
|
||||
"version": "1.0.0",
|
||||
"description": "Glossy blog generator",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://gitlab.com/Nicd/gloss.git"
|
||||
},
|
||||
"author": "Mikko Ahlroth <mikko@ahlroth.fi>",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"bugs": {
|
||||
"url": "https://gitlab.com/Nicd/gloss/issues"
|
||||
},
|
||||
"homepage": "https://gitlab.com/Nicd/gloss#readme",
|
||||
"private": true
|
||||
}
|
35
priv/templates/base.html.glemp
Normal file
35
priv/templates/base.html.glemp
Normal file
|
@ -0,0 +1,35 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>
|
||||
<%= title %>
|
||||
</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<h1>
|
||||
<a href="<%= urls.base %><%= urls.index %>">
|
||||
<%= blog_name %>
|
||||
</a>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<%= raw inner_content %>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div>
|
||||
<%= copyright %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Powered by <a href="https://gitlab.com/Nicd/gloss">gloss</a>.
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
11
priv/templates/list.html.glemp
Normal file
11
priv/templates/list.html.glemp
Normal file
|
@ -0,0 +1,11 @@
|
|||
<section class="list">
|
||||
<% for post in posts %>
|
||||
<% render "list_post.html.glemp" post: post, urls: urls %>
|
||||
<% end %>
|
||||
|
||||
<footer>
|
||||
<nav>
|
||||
<% render "navigation.html.glemp" urls: urls, next: nav.next, previous: nav.previous %>
|
||||
</nav>
|
||||
</footer>
|
||||
</section>
|
21
priv/templates/list_post.html.glemp
Normal file
21
priv/templates/list_post.html.glemp
Normal file
|
@ -0,0 +1,21 @@
|
|||
<section class="post list-post">
|
||||
<header>
|
||||
<h2>
|
||||
<%= post.title %>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<% if post.short_content %>
|
||||
<%= raw post.short_content %>
|
||||
|
||||
<a href="<%= urls.base %><%= post.url %>">
|
||||
Read more…
|
||||
</a>
|
||||
<% else %>
|
||||
<%= raw post.content %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<footer></footer>
|
||||
</section>
|
15
priv/templates/navigation.html.glemp
Normal file
15
priv/templates/navigation.html.glemp
Normal file
|
@ -0,0 +1,15 @@
|
|||
<div class="nav-previous">
|
||||
<% if previous %>
|
||||
<a href="<%= urls.base %><%= previous %>" aria-label="Previous">«</a>
|
||||
<% else %>
|
||||
<span class="nav-none" aria-hidden="true">«</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="nav-next">
|
||||
<% if next %>
|
||||
<a href="<%= urls.base %><%= next %>" aria-label="Next">»</a>
|
||||
<% else %>
|
||||
<span class="nav-none" aria-hidden="true">»</span>
|
||||
<% end %>
|
||||
</div>
|
13
priv/templates/single_post.html.glemp
Normal file
13
priv/templates/single_post.html.glemp
Normal file
|
@ -0,0 +1,13 @@
|
|||
<section class="post single-post">
|
||||
<header>
|
||||
<h2>
|
||||
<%= title %>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<%= raw content %>
|
||||
</div>
|
||||
|
||||
<footer></footer>
|
||||
</section>
|
2950
priv/vendor/marked.esm.mjs
vendored
Normal file
2950
priv/vendor/marked.esm.mjs
vendored
Normal file
File diff suppressed because it is too large
Load diff
3
src/ffi_buffer.mjs
Normal file
3
src/ffi_buffer.mjs
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function to_string(buffer) {
|
||||
return buffer.toString("utf8");
|
||||
}
|
9
src/ffi_exceptions.mjs
Normal file
9
src/ffi_exceptions.mjs
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { Ok, Error } from "./gleam.mjs";
|
||||
|
||||
export function resultify(callback) {
|
||||
try {
|
||||
return new Ok(callback());
|
||||
} catch (err) {
|
||||
return new Error(err);
|
||||
}
|
||||
}
|
5
src/ffi_fs.mjs
Normal file
5
src/ffi_fs.mjs
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { mkdirSync } from "node:fs";
|
||||
|
||||
export function mkdirP(path) {
|
||||
return mkdirSync(path, { recursive: true });
|
||||
}
|
3
src/ffi_meta_url.mjs
Normal file
3
src/ffi_meta_url.mjs
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function metaURL() {
|
||||
return import.meta.url;
|
||||
}
|
11
src/ffi_object.mjs
Normal file
11
src/ffi_object.mjs
Normal file
|
@ -0,0 +1,11 @@
|
|||
export function create() {
|
||||
return {};
|
||||
}
|
||||
|
||||
export function set(obj, prop, val) {
|
||||
return { ...obj, [prop]: val };
|
||||
}
|
||||
|
||||
export function get(obj, prop) {
|
||||
return obj[prop];
|
||||
}
|
3
src/ffi_url.mjs
Normal file
3
src/ffi_url.mjs
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function fromString(str) {
|
||||
return new URL(str);
|
||||
}
|
17
src/gloss.gleam
Normal file
17
src/gloss.gleam
Normal file
|
@ -0,0 +1,17 @@
|
|||
import gleam/result
|
||||
import gleam/io
|
||||
import gloss/builder
|
||||
import gloss/config.{Configuration}
|
||||
|
||||
pub fn main() {
|
||||
let config = config.defaults()
|
||||
let assert Ok(_) = build(config)
|
||||
}
|
||||
|
||||
pub fn build(config: Configuration) {
|
||||
use templates <- result.try(builder.load_templates(config))
|
||||
use db <- result.try(builder.parse(config))
|
||||
use posts <- result.try(builder.render(db, templates, config))
|
||||
io.debug(posts)
|
||||
builder.write(posts, config)
|
||||
}
|
35
src/gloss/builder.gleam
Normal file
35
src/gloss/builder.gleam
Normal file
|
@ -0,0 +1,35 @@
|
|||
import gleam/result
|
||||
import gloss/parser
|
||||
import gloss/rendering/templates.{TemplateCache}
|
||||
import gloss/rendering/database as render_database
|
||||
import gloss/renderer
|
||||
import gloss/writer
|
||||
import gloss/config.{Configuration}
|
||||
import gloss/models/database.{Database}
|
||||
|
||||
pub type BuildError {
|
||||
TemplateLoadError(err: templates.TemplateLoadError)
|
||||
ParseError(err: parser.ParseError)
|
||||
RenderError(err: renderer.RenderError)
|
||||
WriteError(err: writer.WriteError)
|
||||
}
|
||||
|
||||
pub fn load_templates(config: Configuration) {
|
||||
config.rendering.template_loader()
|
||||
|> result.map_error(TemplateLoadError)
|
||||
}
|
||||
|
||||
pub fn parse(config: Configuration) {
|
||||
config.parser()
|
||||
|> result.map_error(ParseError)
|
||||
}
|
||||
|
||||
pub fn render(db: Database, templates: TemplateCache, config: Configuration) {
|
||||
renderer.render(db, config.rendering.html_renderer(templates))
|
||||
|> result.map_error(RenderError)
|
||||
}
|
||||
|
||||
pub fn write(posts: render_database.Database, config: Configuration) {
|
||||
config.writer.posts(posts)
|
||||
|> result.map_error(WriteError)
|
||||
}
|
56
src/gloss/config.gleam
Normal file
56
src/gloss/config.gleam
Normal file
|
@ -0,0 +1,56 @@
|
|||
import gloss/paths.{PathConfiguration}
|
||||
import gloss/paths/post as post_paths
|
||||
import gloss/rendering/html_renderer.{HTMLRenderer}
|
||||
import gloss/rendering/content_renderer.{ContentRenderer}
|
||||
import gloss/rendering/base
|
||||
import gloss/rendering/post
|
||||
import gloss/rendering/templates.{TemplateCache, TemplateLoadError}
|
||||
import gloss/parser.{Parser}
|
||||
import gloss/writer.{Writer}
|
||||
|
||||
pub type Rendering {
|
||||
Rendering(
|
||||
template_loader: fn() -> Result(TemplateCache, TemplateLoadError),
|
||||
copyright: String,
|
||||
content_renderer: ContentRenderer,
|
||||
html_renderer: fn(TemplateCache) -> HTMLRenderer,
|
||||
posts_per_page: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub type Configuration {
|
||||
Configuration(
|
||||
blog_name: String,
|
||||
rendering: Rendering,
|
||||
paths: PathConfiguration,
|
||||
parser: Parser,
|
||||
writer: Writer,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn defaults() -> Configuration {
|
||||
Configuration(
|
||||
blog_name: "",
|
||||
rendering: Rendering(
|
||||
template_loader: templates.default_templates,
|
||||
copyright: "",
|
||||
content_renderer: post.content_renderer(),
|
||||
html_renderer: fn(templates) {
|
||||
HTMLRenderer(
|
||||
base: fn(inner) { base.default(templates, base.base_assigns, inner) },
|
||||
single_post: post.default_single(templates, post.post_to_assigns),
|
||||
)
|
||||
},
|
||||
posts_per_page: 10,
|
||||
),
|
||||
paths: paths.conf(
|
||||
paths.default_base,
|
||||
paths.default_index,
|
||||
paths.default_single_post,
|
||||
),
|
||||
parser: parser.default_parse,
|
||||
writer: Writer(posts: fn(post) {
|
||||
writer.default_posts(post, post_paths.post_to_path)
|
||||
}),
|
||||
)
|
||||
}
|
126
src/gloss/models/database.gleam
Normal file
126
src/gloss/models/database.gleam
Normal file
|
@ -0,0 +1,126 @@
|
|||
import gleam/map.{Map}
|
||||
import gleam/list
|
||||
import gleam/option.{None, Some}
|
||||
import gleam/order.{Order}
|
||||
import gloss/models/post.{Post, Tag}
|
||||
import gloss/utils/date.{Month}
|
||||
import gloss/utils/ordered_tree.{OrderedTree}
|
||||
import gloss/utils/uniqid.{Generator, UniqID}
|
||||
|
||||
pub type PostID =
|
||||
UniqID
|
||||
|
||||
pub type PostWithID {
|
||||
PostWithID(id: PostID, post: Post)
|
||||
}
|
||||
|
||||
pub type TagPosts =
|
||||
Map(Tag, OrderedTree(PostWithID))
|
||||
|
||||
pub type MonthPosts =
|
||||
Map(Month, OrderedTree(PostWithID))
|
||||
|
||||
pub type YearPosts =
|
||||
Map(Int, MonthPosts)
|
||||
|
||||
pub opaque type Database {
|
||||
Database(
|
||||
posts: OrderedTree(PostWithID),
|
||||
tags: TagPosts,
|
||||
years: YearPosts,
|
||||
posts_by_id: Map(PostID, Post),
|
||||
id_generator: Generator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new() -> Database {
|
||||
Database(
|
||||
posts: new_tree(),
|
||||
tags: map.new(),
|
||||
years: map.new(),
|
||||
posts_by_id: map.new(),
|
||||
id_generator: uniqid.new(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn add_post(db: Database, post: Post) -> Database {
|
||||
let post_date = post.get_date(post)
|
||||
let #(id, id_generator) = uniqid.get(db.id_generator)
|
||||
let post_with_id = PostWithID(id, post)
|
||||
|
||||
let posts_by_id = map.insert(db.posts_by_id, id, post)
|
||||
let posts = ordered_tree.insert(db.posts, post_with_id)
|
||||
let tags =
|
||||
list.fold(
|
||||
post.tags,
|
||||
db.tags,
|
||||
fn(acc, tag) {
|
||||
map.update(
|
||||
acc,
|
||||
tag,
|
||||
fn(existing) {
|
||||
case existing {
|
||||
None ->
|
||||
new_tree()
|
||||
|> ordered_tree.insert(post_with_id)
|
||||
Some(posts) -> ordered_tree.insert(posts, post_with_id)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
let years =
|
||||
map.update(
|
||||
db.years,
|
||||
post_date.year,
|
||||
fn(years) {
|
||||
case years {
|
||||
None ->
|
||||
map.from_list([
|
||||
#(
|
||||
post_date.month,
|
||||
new_tree()
|
||||
|> ordered_tree.insert(post_with_id),
|
||||
),
|
||||
])
|
||||
Some(months) ->
|
||||
map.update(
|
||||
months,
|
||||
post_date.month,
|
||||
fn(posts) {
|
||||
case posts {
|
||||
None ->
|
||||
new_tree()
|
||||
|> ordered_tree.insert(post_with_id)
|
||||
Some(posts) -> ordered_tree.insert(posts, post_with_id)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Database(
|
||||
id_generator: id_generator,
|
||||
posts: posts,
|
||||
tags: tags,
|
||||
years: years,
|
||||
posts_by_id: posts_by_id,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_posts_with_ids(
|
||||
db: Database,
|
||||
order: ordered_tree.ListOrdering,
|
||||
) -> List(PostWithID) {
|
||||
db.posts
|
||||
|> ordered_tree.to_list(order)
|
||||
}
|
||||
|
||||
fn new_tree() -> OrderedTree(PostWithID) {
|
||||
ordered_tree.new(comparator)
|
||||
}
|
||||
|
||||
fn comparator(a: PostWithID, b: PostWithID) -> Order {
|
||||
post.comparator(a.post, b.post)
|
||||
}
|
57
src/gloss/models/post.gleam
Normal file
57
src/gloss/models/post.gleam
Normal file
|
@ -0,0 +1,57 @@
|
|||
import gleam/option.{Option}
|
||||
import gleam/order.{Eq, Gt, Lt, Order}
|
||||
import gloss/utils/date.{Date}
|
||||
import gloss/utils/time.{Time}
|
||||
|
||||
pub type PostedAt {
|
||||
JustDate(Date)
|
||||
DateTime(date: Date, time: Time)
|
||||
}
|
||||
|
||||
pub type Header =
|
||||
#(String, String)
|
||||
|
||||
pub type Tag =
|
||||
String
|
||||
|
||||
pub type Post {
|
||||
Post(
|
||||
title: String,
|
||||
slug: String,
|
||||
tags: List(Tag),
|
||||
headers: List(Header),
|
||||
content: String,
|
||||
short_content: Option(String),
|
||||
date: PostedAt,
|
||||
order: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_date(post: Post) -> Date {
|
||||
case post.date {
|
||||
JustDate(date) -> date
|
||||
DateTime(date, ..) -> date
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_time(post: Post) -> Time {
|
||||
case post.date {
|
||||
JustDate(..) -> Time(hours: 0, minutes: 0)
|
||||
DateTime(time: time, ..) -> time
|
||||
}
|
||||
}
|
||||
|
||||
pub fn comparator(a: Post, b: Post) -> Order {
|
||||
let a_date = get_date(a)
|
||||
let b_date = get_date(b)
|
||||
|
||||
case date.compare(a_date, b_date) {
|
||||
Lt -> Lt
|
||||
Gt -> Gt
|
||||
Eq -> {
|
||||
let a_time = get_time(a)
|
||||
let b_time = get_time(b)
|
||||
time.compare(a_time, b_time)
|
||||
}
|
||||
}
|
||||
}
|
56
src/gloss/parser.gleam
Normal file
56
src/gloss/parser.gleam
Normal file
|
@ -0,0 +1,56 @@
|
|||
import gleam/result
|
||||
import gleam/list
|
||||
import gleam/regex
|
||||
import gloss/utils/fs
|
||||
import gloss/models/database.{Database}
|
||||
import gloss/parser/post
|
||||
|
||||
const default_data_path = "./data"
|
||||
|
||||
pub type Parser =
|
||||
fn() -> Result(Database, ParseError)
|
||||
|
||||
pub type ParseError {
|
||||
FileError(path: String, err: fs.FSError)
|
||||
PostParseError(filename: String, err: post.ParseError)
|
||||
}
|
||||
|
||||
pub fn default_parse() -> Result(Database, ParseError) {
|
||||
let db = database.new()
|
||||
parse_posts(post_path(), db)
|
||||
}
|
||||
|
||||
pub fn parse_posts(path: String, db: Database) -> Result(Database, ParseError) {
|
||||
use filenames <- result.try(
|
||||
fs.readdir(path)
|
||||
|> result.map_error(fn(err) { FileError(path, err) }),
|
||||
)
|
||||
|
||||
let assert Ok(filename_regex) =
|
||||
regex.compile(
|
||||
post.filename_regex,
|
||||
regex.Options(case_insensitive: False, multi_line: False),
|
||||
)
|
||||
|
||||
let filenames =
|
||||
list.filter(filenames, fn(file) { regex.check(filename_regex, file) })
|
||||
|
||||
use posts <- result.try(result.all(list.map(
|
||||
filenames,
|
||||
fn(file) {
|
||||
use contents <- result.try(
|
||||
fs.read_file(path <> "/" <> file)
|
||||
|> result.map_error(fn(err) { FileError(file, err) }),
|
||||
)
|
||||
|
||||
post.parse(file, contents)
|
||||
|> result.map_error(fn(err) { PostParseError(file, err) })
|
||||
},
|
||||
)))
|
||||
|
||||
Ok(list.fold(posts, db, database.add_post))
|
||||
}
|
||||
|
||||
fn post_path() {
|
||||
default_data_path <> "/posts"
|
||||
}
|
171
src/gloss/parser/post.gleam
Normal file
171
src/gloss/parser/post.gleam
Normal file
|
@ -0,0 +1,171 @@
|
|||
import gleam/string
|
||||
import gleam/result
|
||||
import gleam/list
|
||||
import gleam/option
|
||||
import gleam/int
|
||||
import gleam/bool
|
||||
import gleam/regex
|
||||
import gloss/models/post.{Header, Post, PostedAt, Tag}
|
||||
import gloss/utils/date.{Date}
|
||||
|
||||
pub const filename_regex = "^\\d{4}-\\d\\d-\\d\\d-.*\\.md$"
|
||||
|
||||
const filename_separator = "-"
|
||||
|
||||
const filename_postfix = ".md"
|
||||
|
||||
const tag_separator = ","
|
||||
|
||||
const header_separator = ":"
|
||||
|
||||
const split_re = "<!--\\s*SPLIT\\s*-->"
|
||||
|
||||
type FilenameMeta {
|
||||
FilenameMeta(date: PostedAt, order: Int, slug: String)
|
||||
}
|
||||
|
||||
pub type ParseError {
|
||||
EmptyFile
|
||||
HeaderMissing
|
||||
MalformedFilename
|
||||
YearNotInt
|
||||
MonthNotInt
|
||||
DayNotInt
|
||||
InvalidDate
|
||||
MalformedHeader(header: String)
|
||||
}
|
||||
|
||||
pub fn parse(filename: String, contents: String) -> Result(Post, ParseError) {
|
||||
let lines = string.split(contents, "\n")
|
||||
|
||||
use title <- try(list.first(lines), EmptyFile)
|
||||
use rest <- try(list.rest(lines), HeaderMissing)
|
||||
|
||||
use tags <- try(list.first(rest), HeaderMissing)
|
||||
use rest <- try(list.rest(rest), HeaderMissing)
|
||||
|
||||
let filename = case string.ends_with(filename, filename_postfix) {
|
||||
True ->
|
||||
string.slice(
|
||||
filename,
|
||||
0,
|
||||
string.length(filename) - string.length(filename_postfix),
|
||||
)
|
||||
False -> filename
|
||||
}
|
||||
use meta <- result.try(parse_filename_meta(filename))
|
||||
|
||||
let #(headers, body) =
|
||||
list.split_while(rest, fn(line) { !string.is_empty(line) })
|
||||
|
||||
let tags = parse_tags(tags)
|
||||
use headers <- result.try(parse_headers(headers))
|
||||
let body = string.join(body, "\n")
|
||||
let short_content = parse_short_content(body)
|
||||
|
||||
Ok(Post(
|
||||
title: title,
|
||||
slug: meta.slug,
|
||||
date: meta.date,
|
||||
content: body,
|
||||
headers: headers,
|
||||
order: meta.order,
|
||||
short_content: short_content,
|
||||
tags: tags,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_filename_meta(filename: String) -> Result(FilenameMeta, ParseError) {
|
||||
let filename_parts = string.split(filename, filename_separator)
|
||||
let #(meta_parts, rest_parts) = list.split(filename_parts, 4)
|
||||
|
||||
use #(year_str, month_str, day_str, maybe_order) <- result.try(case
|
||||
meta_parts
|
||||
{
|
||||
[y, m, d, o] -> Ok(#(y, m, d, o))
|
||||
_ -> Error(MalformedFilename)
|
||||
})
|
||||
|
||||
use year <- try(int.parse(year_str), YearNotInt)
|
||||
use month_int <- try(int.parse(month_str), MonthNotInt)
|
||||
use month <- try(date.parse_month(month_int), InvalidDate)
|
||||
use day <- try(int.parse(day_str), DayNotInt)
|
||||
let #(order, slug) = parse_order_slug(maybe_order, rest_parts)
|
||||
|
||||
let date = Date(year: year, month: month, day: day)
|
||||
use <- bool.guard(date.is_valid_date(date), Error(InvalidDate))
|
||||
|
||||
Ok(FilenameMeta(
|
||||
date: post.JustDate(date),
|
||||
order: option.unwrap(order, 0),
|
||||
slug: slug,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_order_slug(
|
||||
maybe_order: String,
|
||||
rest_parts: List(String),
|
||||
) -> #(option.Option(Int), String) {
|
||||
let fail_case = fn() {
|
||||
#(option.None, string.join([maybe_order, ..rest_parts], filename_separator))
|
||||
}
|
||||
|
||||
case string.length(maybe_order) {
|
||||
o if o >= 1 && o <= 2 -> {
|
||||
case int.parse(maybe_order) {
|
||||
Ok(order) -> #(
|
||||
option.Some(order),
|
||||
string.join(rest_parts, filename_separator),
|
||||
)
|
||||
_ -> fail_case()
|
||||
}
|
||||
}
|
||||
_ -> fail_case()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_tags(tags: String) -> List(Tag) {
|
||||
tags
|
||||
|> string.split(tag_separator)
|
||||
|> list.map(string.trim)
|
||||
}
|
||||
|
||||
fn parse_headers(headers: List(String)) -> Result(List(Header), ParseError) {
|
||||
headers
|
||||
|> list.map(parse_header)
|
||||
|> result.all()
|
||||
}
|
||||
|
||||
fn parse_header(header: String) -> Result(Header, ParseError) {
|
||||
let header_parts = string.split(header, header_separator)
|
||||
let parts_amount = list.length(header_parts)
|
||||
|
||||
use <- bool.guard(parts_amount < 2, Error(MalformedHeader(header)))
|
||||
|
||||
let assert Ok(name) = list.first(header_parts)
|
||||
let assert Ok(rest) = list.rest(header_parts)
|
||||
let value = string.join(rest, header_separator)
|
||||
|
||||
let name = string.trim(name)
|
||||
let value = string.trim(value)
|
||||
|
||||
Ok(#(name, value))
|
||||
}
|
||||
|
||||
fn parse_short_content(body: String) -> option.Option(String) {
|
||||
let assert Ok(re) =
|
||||
regex.compile(
|
||||
split_re,
|
||||
regex.Options(case_insensitive: False, multi_line: False),
|
||||
)
|
||||
let body_parts = regex.split(re, body)
|
||||
|
||||
case body_parts {
|
||||
[_] -> option.None
|
||||
[first, ..] -> option.Some(first)
|
||||
}
|
||||
}
|
||||
|
||||
fn try(value: Result(a, b), error: c, if_ok: fn(a) -> Result(d, c)) {
|
||||
result.try(result.replace_error(value, error), if_ok)
|
||||
}
|
40
src/gloss/paths.gleam
Normal file
40
src/gloss/paths.gleam
Normal file
|
@ -0,0 +1,40 @@
|
|||
import gloss/models/post.{Post}
|
||||
import gloss/paths/post as post_paths
|
||||
|
||||
pub const default_base = ""
|
||||
|
||||
pub const default_index = "/index.html"
|
||||
|
||||
pub opaque type PathConfiguration {
|
||||
PathConfiguration(
|
||||
base: String,
|
||||
index: String,
|
||||
single_post: fn(Post) -> String,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn conf(
|
||||
base: String,
|
||||
index: String,
|
||||
single_post: fn(Post) -> String,
|
||||
) -> PathConfiguration {
|
||||
PathConfiguration(base, index, single_post)
|
||||
}
|
||||
|
||||
pub fn base(conf: PathConfiguration) -> String {
|
||||
conf.base
|
||||
}
|
||||
|
||||
pub fn index(conf: PathConfiguration) -> String {
|
||||
conf.index
|
||||
}
|
||||
|
||||
pub fn post(post: Post, conf: PathConfiguration) -> String {
|
||||
conf.single_post(post)
|
||||
}
|
||||
|
||||
pub fn default_single_post(post: Post) -> String {
|
||||
let post_path = post_paths.post_to_path(post)
|
||||
|
||||
"/" <> post_path.date_path <> "/" <> post_path.filename
|
||||
}
|
29
src/gloss/paths/post.gleam
Normal file
29
src/gloss/paths/post.gleam
Normal file
|
@ -0,0 +1,29 @@
|
|||
import gleam/string
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gloss/models/post.{Post}
|
||||
import gloss/utils/date
|
||||
|
||||
pub type PostPath {
|
||||
PostPath(date_path: String, filename: String)
|
||||
}
|
||||
|
||||
pub fn post_to_path(post: Post) -> PostPath {
|
||||
let post_date = post.get_date(post)
|
||||
let date_parts =
|
||||
list.map(
|
||||
[post_date.year, date.month_to_int(post_date.month), post_date.day],
|
||||
pad_int,
|
||||
)
|
||||
|
||||
PostPath(
|
||||
date_path: string.join(date_parts, "/"),
|
||||
filename: post.slug <> ".html",
|
||||
)
|
||||
}
|
||||
|
||||
fn pad_int(number: Int) -> String {
|
||||
number
|
||||
|> int.to_string()
|
||||
|> string.pad_left(to: 2, with: "0")
|
||||
}
|
118
src/gloss/renderer.gleam
Normal file
118
src/gloss/renderer.gleam
Normal file
|
@ -0,0 +1,118 @@
|
|||
import gleam/result
|
||||
import gleam/list
|
||||
import gleam/map.{Map}
|
||||
import gleam/string_builder
|
||||
import glemplate/renderer as gl_renderer
|
||||
import gloss/rendering/html_renderer.{HTMLRenderer}
|
||||
import gloss/rendering/content_renderer.{ContentRenderer}
|
||||
import gloss/rendering/base.{InnerContentRenderer}
|
||||
import gloss/rendering/database.{
|
||||
Database as RenderDatabase, RenderedPost, RenderedSinglePost,
|
||||
} as render_database
|
||||
import gloss/rendering/index
|
||||
import gloss/models/database.{Database, PostID}
|
||||
import gloss/utils/ordered_tree
|
||||
import gloss/utils/fs
|
||||
|
||||
const default_output_path = "./output"
|
||||
|
||||
pub type RenderError {
|
||||
RenderError(err: gl_renderer.RenderError)
|
||||
FileSystemError(err: String, inner: fs.FSError)
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
db: Database,
|
||||
content_renderer: ContentRenderer,
|
||||
html_renderer: HTMLRenderer,
|
||||
) -> Result(RenderDatabase, RenderError) {
|
||||
use existed <- result.try(
|
||||
fs.exists(default_output_path)
|
||||
|> result.map_error(fn(err) {
|
||||
FileSystemError(
|
||||
"Couldn't check existence of path " <> default_output_path,
|
||||
err,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
use _ <- result.try(case existed {
|
||||
True -> Ok(Nil)
|
||||
False -> {
|
||||
fs.mkdir(default_output_path)
|
||||
|> result.map_error(fn(err) {
|
||||
FileSystemError(
|
||||
"Couldn't create output path " <> default_output_path,
|
||||
err,
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let post_contents = render_post_contents(db, content_renderer)
|
||||
use posts <- result.try(render_posts(db, html_renderer))
|
||||
let index = form_index(db)
|
||||
|
||||
Ok(render_database.Database(
|
||||
orig: db,
|
||||
posts: post_contents,
|
||||
single_posts: posts,
|
||||
index: index,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn render_post_contents(
|
||||
db: Database,
|
||||
renderer: ContentRenderer,
|
||||
) -> Map(PostID, RenderedPost) {
|
||||
let all_posts = database.get_posts_with_ids(db, ordered_tree.Desc)
|
||||
|
||||
let posts =
|
||||
all_posts
|
||||
|> list.map(fn(post_with_id) {
|
||||
let content = renderer.post(post_with_id.post)
|
||||
#(post_with_id.id, RenderedPost(post_with_id.post, content: content))
|
||||
})
|
||||
|
||||
map.from_list(posts)
|
||||
}
|
||||
|
||||
pub fn render_posts(
|
||||
db: Database,
|
||||
renderer: HTMLRenderer,
|
||||
) -> Result(List(RenderedSinglePost), RenderError) {
|
||||
let all_posts = database.get_posts_with_ids(db, ordered_tree.Desc)
|
||||
|
||||
let posts =
|
||||
all_posts
|
||||
|> list.map(fn(post_with_id) {
|
||||
use content <- result.try(render_inside_base(
|
||||
db,
|
||||
fn() { renderer.single_post(post_with_id.post, db) },
|
||||
renderer,
|
||||
))
|
||||
Ok(RenderedSinglePost(
|
||||
post_with_id.post,
|
||||
string_builder.to_string(content),
|
||||
))
|
||||
})
|
||||
|
||||
use post_contents <- result.try(
|
||||
result.all(posts)
|
||||
|> result.map_error(RenderError),
|
||||
)
|
||||
|
||||
Ok(post_contents)
|
||||
}
|
||||
|
||||
pub fn form_index(db: Database) -> List(PostID) {
|
||||
index.build(db)
|
||||
}
|
||||
|
||||
fn render_inside_base(
|
||||
db: Database,
|
||||
inner_renderer: InnerContentRenderer,
|
||||
renderer: HTMLRenderer,
|
||||
) -> Result(string_builder.StringBuilder, gl_renderer.RenderError) {
|
||||
renderer.base(inner_renderer)(db)
|
||||
}
|
56
src/gloss/rendering/base.gleam
Normal file
56
src/gloss/rendering/base.gleam
Normal file
|
@ -0,0 +1,56 @@
|
|||
import gleam/string_builder
|
||||
import gleam/result
|
||||
import gleam/map
|
||||
import glemplate/assigns.{Assigns, String as GlString}
|
||||
import glemplate/renderer.{RenderError}
|
||||
import glemplate/html
|
||||
import glemplate/ast.{Template}
|
||||
import gloss/models/database.{Database}
|
||||
import gloss/rendering/templates.{TemplateCache}
|
||||
import gloss/rendering/templates/builtin.{Base}
|
||||
|
||||
pub type InnerContentRenderer =
|
||||
fn() -> Result(string_builder.StringBuilder, RenderError)
|
||||
|
||||
pub type BaseRenderer =
|
||||
fn(Database) -> Result(string_builder.StringBuilder, RenderError)
|
||||
|
||||
pub type BaseAssigner =
|
||||
fn(Database, String) -> Assigns
|
||||
|
||||
pub fn default(
|
||||
tpl_cache: TemplateCache,
|
||||
assigner: BaseAssigner,
|
||||
inner: InnerContentRenderer,
|
||||
) -> BaseRenderer {
|
||||
render(templates.get_builtin(tpl_cache, Base), tpl_cache, assigner, inner)
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
single_post_tpl: Template,
|
||||
tpl_cache: TemplateCache,
|
||||
assigner: BaseAssigner,
|
||||
inner: InnerContentRenderer,
|
||||
) -> BaseRenderer {
|
||||
fn(db: Database) {
|
||||
use inner <- result.try(inner())
|
||||
|
||||
html.render(
|
||||
single_post_tpl,
|
||||
assigner(db, string_builder.to_string(inner)),
|
||||
templates.cache_to_map(tpl_cache),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn base_assigns(_db: Database, inner: String) {
|
||||
assigns.new()
|
||||
|> assigns.add_string("title", "Random Notes")
|
||||
|> assigns.add_string("blog_name", "Random Notes")
|
||||
|> assigns.add_string("copyright", "© Nicd")
|
||||
|> assigns.add_map(
|
||||
"urls",
|
||||
map.from_list([#("base", GlString("")), #("index", GlString("/"))]),
|
||||
)
|
||||
|> assigns.add_string("inner_content", inner)
|
||||
}
|
5
src/gloss/rendering/content_renderer.gleam
Normal file
5
src/gloss/rendering/content_renderer.gleam
Normal file
|
@ -0,0 +1,5 @@
|
|||
import gloss/rendering/post.{ContentRenderer as PostContentRenderer}
|
||||
|
||||
pub type ContentRenderer {
|
||||
ContentRenderer(post: PostContentRenderer)
|
||||
}
|
33
src/gloss/rendering/database.gleam
Normal file
33
src/gloss/rendering/database.gleam
Normal file
|
@ -0,0 +1,33 @@
|
|||
import gleam/option.{Option}
|
||||
import gleam/map.{Map}
|
||||
import gloss/models/database.{Database as OrigDatabase, PostID} as orig_database
|
||||
import gloss/models/post.{Post}
|
||||
|
||||
pub type PostList =
|
||||
List(PostID)
|
||||
|
||||
pub type RenderedContent {
|
||||
RenderedContent(full: String, short: Option(String))
|
||||
}
|
||||
|
||||
pub type RenderedPost {
|
||||
RenderedPost(orig: Post, content: RenderedContent)
|
||||
}
|
||||
|
||||
pub type RenderedSinglePost {
|
||||
RenderedSinglePost(orig: Post, content: String)
|
||||
}
|
||||
|
||||
pub type RenderedPage {
|
||||
ListPage(page: Int, content: String)
|
||||
}
|
||||
|
||||
pub type Database {
|
||||
Database(
|
||||
orig: OrigDatabase,
|
||||
posts: Map(PostID, RenderedPost),
|
||||
single_posts: List(RenderedSinglePost),
|
||||
index: PostList,
|
||||
index_pages: List(RenderedPage),
|
||||
)
|
||||
}
|
9
src/gloss/rendering/html_renderer.gleam
Normal file
9
src/gloss/rendering/html_renderer.gleam
Normal file
|
@ -0,0 +1,9 @@
|
|||
import gloss/rendering/base.{BaseRenderer, InnerContentRenderer}
|
||||
import gloss/rendering/post.{SinglePostRenderer}
|
||||
|
||||
pub type HTMLRenderer {
|
||||
HTMLRenderer(
|
||||
base: fn(InnerContentRenderer) -> BaseRenderer,
|
||||
single_post: SinglePostRenderer,
|
||||
)
|
||||
}
|
83
src/gloss/rendering/index.gleam
Normal file
83
src/gloss/rendering/index.gleam
Normal file
|
@ -0,0 +1,83 @@
|
|||
import gleam/list
|
||||
import gleam/map
|
||||
import gleam/option
|
||||
import glemplate/ast.{Template}
|
||||
import glemplate/assigns.{Assigns}
|
||||
import glemplate/html
|
||||
import gloss/models/database.{Database}
|
||||
import gloss/models/post
|
||||
import gloss/rendering/templates.{TemplateCache}
|
||||
import gloss/rendering/database.{ListPage,
|
||||
PostList, RenderedPage} as renderer_database
|
||||
import gloss/rendering/post as post_renderer
|
||||
import gloss/utils/ordered_tree
|
||||
|
||||
pub type IndexAssigner =
|
||||
fn(PostList, renderer_database.Database, Nav) -> Assigns
|
||||
|
||||
pub type Nav {
|
||||
Nav(total: Int, start: Int, end: Int)
|
||||
}
|
||||
|
||||
pub fn build(db: Database) -> PostList {
|
||||
db
|
||||
|> database.get_posts_with_ids(ordered_tree.Desc)
|
||||
|> list.sort(fn(a, b) { post.comparator(a.post, b.post) })
|
||||
|> list.map(fn(item) { item.id })
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
posts: PostList,
|
||||
db: renderer_database.Database,
|
||||
tpl: Template,
|
||||
tpl_cache: TemplateCache,
|
||||
posts_per_page: Int,
|
||||
assigner: IndexAssigner,
|
||||
) -> List(RenderedPage) {
|
||||
let posts_for_pages = list.sized_chunk(posts, posts_per_page)
|
||||
let total_pages = list.length(posts_for_pages)
|
||||
|
||||
list.index_map(
|
||||
posts_for_pages,
|
||||
fn(i, posts_for_page) {
|
||||
let start = i * posts_per_page
|
||||
let nav =
|
||||
Nav(
|
||||
total: total_pages,
|
||||
start: start,
|
||||
end: start + list.length(posts_for_page),
|
||||
)
|
||||
render_page(posts_for_page, db, tpl, tpl_cache, nav, assigner)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn render_page(
|
||||
posts: PostList,
|
||||
db: renderer_database.Database,
|
||||
tpl: Template,
|
||||
tpl_cache: TemplateCache,
|
||||
nav: Nav,
|
||||
assigner: IndexAssigner,
|
||||
) -> RenderedPage {
|
||||
todo
|
||||
}
|
||||
|
||||
pub fn default_assigns(
|
||||
posts: PostList,
|
||||
db: renderer_database.Database,
|
||||
nav: Nav,
|
||||
) -> Assigns {
|
||||
assigns.new()
|
||||
|> assigns.add_list(
|
||||
"posts",
|
||||
list.map(
|
||||
posts,
|
||||
fn(post_id) {
|
||||
let assert Ok(post) = map.get(db.posts, post_id)
|
||||
|
||||
assigns.Map(post_renderer.list_post_to_assigns(post.orig, db.orig))
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
1
src/gloss/rendering/list.gleam
Normal file
1
src/gloss/rendering/list.gleam
Normal file
|
@ -0,0 +1 @@
|
|||
|
79
src/gloss/rendering/post.gleam
Normal file
79
src/gloss/rendering/post.gleam
Normal file
|
@ -0,0 +1,79 @@
|
|||
import gleam/string_builder
|
||||
import gleam/map
|
||||
import gleam/list
|
||||
import gleam/option
|
||||
import glemplate/html
|
||||
import glemplate/ast.{Template}
|
||||
import glemplate/renderer.{RenderError}
|
||||
import glemplate/assigns.{Assigns}
|
||||
import gloss/models/post.{Post}
|
||||
import gloss/models/database.{Database}
|
||||
import gloss/rendering/templates/builtin.{SinglePost}
|
||||
import gloss/rendering/templates.{TemplateCache}
|
||||
import gloss/rendering/database.{RenderedContent} as renderer_database
|
||||
import gloss/utils/marked
|
||||
|
||||
pub type ContentRenderer =
|
||||
fn(Post) -> RenderedContent
|
||||
|
||||
pub type SinglePostRenderer =
|
||||
fn(Post, Database) -> Result(string_builder.StringBuilder, RenderError)
|
||||
|
||||
pub type SinglePostAssigner =
|
||||
fn(Post, Database) -> Assigns
|
||||
|
||||
pub fn default_single(
|
||||
tpl_cache: TemplateCache,
|
||||
assigner: SinglePostAssigner,
|
||||
) -> SinglePostRenderer {
|
||||
render_single(
|
||||
templates.get_builtin(tpl_cache, SinglePost),
|
||||
tpl_cache,
|
||||
assigner,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn content_renderer() {
|
||||
fn(post: Post) {
|
||||
let full = marked.default_parse(post.content)
|
||||
let short = option.map(post.short_content, marked.default_parse)
|
||||
RenderedContent(full: full, short: short)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_single(
|
||||
single_post_tpl: Template,
|
||||
tpl_cache: TemplateCache,
|
||||
assigner: SinglePostAssigner,
|
||||
) -> SinglePostRenderer {
|
||||
fn(post: Post, db: Database) {
|
||||
let content = marked.default_parse(post.content)
|
||||
|
||||
html.render(
|
||||
single_post_tpl,
|
||||
assigner(Post(..post, content: content), db),
|
||||
templates.cache_to_map(tpl_cache),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post_to_assigns(post: Post, _db: Database) -> Assigns {
|
||||
assigns.new()
|
||||
|> assigns.add_string("title", post.title)
|
||||
|> assigns.add_string("slug", post.slug)
|
||||
|> assigns.add_list(
|
||||
"tags",
|
||||
list.map(post.tags, fn(tag) { assigns.String(tag) }),
|
||||
)
|
||||
|> assigns.add_map(
|
||||
"headers",
|
||||
list.map(post.headers, fn(hdr) { #(hdr.0, assigns.String(hdr.1)) })
|
||||
|> map.from_list(),
|
||||
)
|
||||
|> assigns.add_string("short_content", option.unwrap(post.short_content, ""))
|
||||
|> assigns.add_string("content", post.content)
|
||||
}
|
||||
|
||||
pub fn list_post_to_assigns(post: Post, db: Database) -> Assigns {
|
||||
post_to_assigns(post, db)
|
||||
}
|
89
src/gloss/rendering/templates.gleam
Normal file
89
src/gloss/rendering/templates.gleam
Normal file
|
@ -0,0 +1,89 @@
|
|||
import gleam/map.{Map}
|
||||
import gleam/result
|
||||
import gleam/string
|
||||
import glemplate/parser
|
||||
import glemplate/ast.{Template as GlTemplate}
|
||||
import gloss/utils/fs
|
||||
import gloss/utils/priv
|
||||
import gloss/rendering/templates/builtin.{BuiltinTemplate}
|
||||
|
||||
pub type Template {
|
||||
Builtin(BuiltinTemplate)
|
||||
Other(name: String)
|
||||
}
|
||||
|
||||
pub type TemplateCache {
|
||||
TemplateCache(
|
||||
base: GlTemplate,
|
||||
single_post: GlTemplate,
|
||||
list: GlTemplate,
|
||||
others: Map(String, GlTemplate),
|
||||
)
|
||||
}
|
||||
|
||||
pub type TemplateLoadError {
|
||||
FS(name: String, err: fs.FSError)
|
||||
ParseError(name: String, err: String)
|
||||
}
|
||||
|
||||
pub fn default_templates() -> Result(TemplateCache, TemplateLoadError) {
|
||||
use base <- result.try(load_default(
|
||||
builtin.base_tpl_name,
|
||||
builtin.base_default_tpl,
|
||||
))
|
||||
use single_post <- result.try(load_default(
|
||||
builtin.single_post_tpl_name,
|
||||
builtin.single_post_default_tpl,
|
||||
))
|
||||
use list <- result.try(load_default(
|
||||
builtin.list_tpl_name,
|
||||
builtin.list_default_tpl,
|
||||
))
|
||||
|
||||
Ok(TemplateCache(
|
||||
base: base,
|
||||
single_post: single_post,
|
||||
list: list,
|
||||
others: map.new(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn load(name: String, path: String) -> Result(GlTemplate, TemplateLoadError) {
|
||||
use tpl <- result.try(
|
||||
fs.read_file(path)
|
||||
|> result.map_error(fn(e) { FS(name, e) }),
|
||||
)
|
||||
use tpl <- result.try(
|
||||
parser.parse_to_template(tpl, name)
|
||||
|> result.map_error(fn(e) { ParseError(name, string.inspect(e)) }),
|
||||
)
|
||||
|
||||
Ok(tpl)
|
||||
}
|
||||
|
||||
pub fn get_builtin(cache: TemplateCache, tpl: BuiltinTemplate) -> GlTemplate {
|
||||
case tpl {
|
||||
builtin.Base -> cache.base
|
||||
builtin.SinglePost -> cache.single_post
|
||||
builtin.List -> cache.list
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(cache: TemplateCache, tpl: Template) -> Result(GlTemplate, Nil) {
|
||||
case tpl {
|
||||
Builtin(t) -> Ok(get_builtin(cache, t))
|
||||
Other(t) -> map.get(cache.others, t)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cache_to_map(cache: TemplateCache) -> Map(String, GlTemplate) {
|
||||
map.from_list([
|
||||
#(builtin.base_tpl_name, cache.base),
|
||||
#(builtin.single_post_tpl_name, cache.single_post),
|
||||
])
|
||||
|> map.merge(cache.others)
|
||||
}
|
||||
|
||||
fn load_default(name: String, file_name: String) {
|
||||
load(name, priv.path() <> "/" <> builtin.templates_path <> "/" <> file_name)
|
||||
}
|
19
src/gloss/rendering/templates/builtin.gleam
Normal file
19
src/gloss/rendering/templates/builtin.gleam
Normal file
|
@ -0,0 +1,19 @@
|
|||
pub const templates_path = "templates"
|
||||
|
||||
pub const base_tpl_name = "base"
|
||||
|
||||
pub const base_default_tpl = "base.html.glemp"
|
||||
|
||||
pub const single_post_tpl_name = "single_post"
|
||||
|
||||
pub const single_post_default_tpl = "single_post.html.glemp"
|
||||
|
||||
pub const list_tpl_name = "list"
|
||||
|
||||
pub const list_default_tpl = "list.html.glemp"
|
||||
|
||||
pub type BuiltinTemplate {
|
||||
Base
|
||||
SinglePost
|
||||
List
|
||||
}
|
4
src/gloss/utils/buffer.gleam
Normal file
4
src/gloss/utils/buffer.gleam
Normal file
|
@ -0,0 +1,4 @@
|
|||
pub type Buffer
|
||||
|
||||
@external(javascript, "../../ffi_buffer.mjs", "to_string")
|
||||
pub fn to_string(buf buf: Buffer) -> String
|
116
src/gloss/utils/date.gleam
Normal file
116
src/gloss/utils/date.gleam
Normal file
|
@ -0,0 +1,116 @@
|
|||
import gleam/bool
|
||||
import gleam/order.{Eq, Gt, Lt, Order}
|
||||
|
||||
pub type Month {
|
||||
Jan
|
||||
Feb
|
||||
Mar
|
||||
Apr
|
||||
May
|
||||
Jun
|
||||
Jul
|
||||
Aug
|
||||
Sep
|
||||
Oct
|
||||
Nov
|
||||
Dec
|
||||
}
|
||||
|
||||
/// A date with 1-indexed years and days
|
||||
pub type Date {
|
||||
Date(year: Int, month: Month, day: Int)
|
||||
}
|
||||
|
||||
pub fn parse_month(month_int: Int) -> Result(Month, Nil) {
|
||||
case month_int {
|
||||
1 -> Ok(Jan)
|
||||
2 -> Ok(Feb)
|
||||
3 -> Ok(Mar)
|
||||
4 -> Ok(Apr)
|
||||
5 -> Ok(May)
|
||||
6 -> Ok(Jun)
|
||||
7 -> Ok(Jul)
|
||||
8 -> Ok(Aug)
|
||||
9 -> Ok(Sep)
|
||||
10 -> Ok(Oct)
|
||||
11 -> Ok(Nov)
|
||||
12 -> Ok(Dec)
|
||||
_other -> Error(Nil)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn days_in_month(month: Month, year: Int) {
|
||||
case month {
|
||||
Jan -> 31
|
||||
Feb -> {
|
||||
case year % 4 {
|
||||
0 -> {
|
||||
case year % 100 {
|
||||
0 -> {
|
||||
case year % 400 {
|
||||
0 -> 29
|
||||
_ -> 28
|
||||
}
|
||||
}
|
||||
_ -> 29
|
||||
}
|
||||
}
|
||||
_ -> 28
|
||||
}
|
||||
}
|
||||
Mar -> 31
|
||||
Apr -> 30
|
||||
May -> 31
|
||||
Jun -> 30
|
||||
Jul -> 31
|
||||
Aug -> 31
|
||||
Sep -> 30
|
||||
Oct -> 31
|
||||
Nov -> 30
|
||||
Dec -> 31
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_valid_date(date: Date) -> Bool {
|
||||
use <- bool.guard(date.day < 1, False)
|
||||
use <- bool.guard(date.day <= days_in_month(date.month, date.year), False)
|
||||
True
|
||||
}
|
||||
|
||||
/// Compare if `a` is before (lower than) `b`.
|
||||
pub fn compare(a: Date, b: Date) -> Order {
|
||||
case a.year, b.year {
|
||||
a_year, b_year if a_year < b_year -> Lt
|
||||
a_year, b_year if a_year > b_year -> Gt
|
||||
_, _ -> {
|
||||
case month_to_int(a.month), month_to_int(b.month) {
|
||||
a_int, b_int if a_int < b_int -> Lt
|
||||
a_int, b_int if a_int > b_int -> Gt
|
||||
_, _ -> {
|
||||
case a.day, b.day {
|
||||
a_day, b_day if a_day < b_day -> Lt
|
||||
a_day, b_day if a_day > b_day -> Gt
|
||||
_, _ -> Eq
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn month_to_int(month: Month) -> Int {
|
||||
case month {
|
||||
Jan -> 1
|
||||
Feb -> 2
|
||||
Mar -> 3
|
||||
Apr -> 4
|
||||
May -> 5
|
||||
Jun -> 6
|
||||
Jul -> 7
|
||||
Aug -> 8
|
||||
Sep -> 9
|
||||
Oct -> 10
|
||||
Nov -> 11
|
||||
Dec -> 12
|
||||
}
|
||||
}
|
10
src/gloss/utils/exceptions.gleam
Normal file
10
src/gloss/utils/exceptions.gleam
Normal file
|
@ -0,0 +1,10 @@
|
|||
/// A result (of type a) from an external function that can also raise an error
|
||||
/// (of type b).
|
||||
pub type CanRaise(a, b) {
|
||||
CanRaise(value: a, error: b)
|
||||
}
|
||||
|
||||
/// Convert a callback function (that should call an external function) from one
|
||||
/// that can raise to one that will return a Result.
|
||||
@external(javascript, "../../ffi_exceptions.mjs", "resultify")
|
||||
pub fn resultify(callback callback: fn() -> CanRaise(a, b)) -> Result(a, b)
|
64
src/gloss/utils/fs.gleam
Normal file
64
src/gloss/utils/fs.gleam
Normal file
|
@ -0,0 +1,64 @@
|
|||
import gleam/result
|
||||
import gleam/list
|
||||
import gleam/javascript/array.{Array}
|
||||
import gloss/utils/buffer.{Buffer}
|
||||
import gloss/utils/exceptions.{CanRaise}
|
||||
import gloss/utils/js.{Undefined}
|
||||
|
||||
pub type FSError
|
||||
|
||||
pub fn read_file(path: String) -> Result(String, FSError) {
|
||||
use contents <- result.try(exceptions.resultify(fn() { do_read_file(path) }))
|
||||
|
||||
Ok(buffer.to_string(contents))
|
||||
}
|
||||
|
||||
pub fn readdir(path: String) -> Result(List(String), FSError) {
|
||||
use files <- result.try(exceptions.resultify(fn() { do_readdir(path) }))
|
||||
|
||||
Ok(list.map(array.to_list(files), buffer.to_string))
|
||||
}
|
||||
|
||||
pub fn mkdir(path: String) -> Result(Nil, FSError) {
|
||||
use _undefined <- result.try(exceptions.resultify(fn() { do_mkdir(path) }))
|
||||
|
||||
Ok(Nil)
|
||||
}
|
||||
|
||||
pub fn mkdir_p(path: String) -> Result(String, FSError) {
|
||||
use created <- result.try(exceptions.resultify(fn() { do_mkdir_p(path) }))
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
pub fn exists(path: String) -> Result(Bool, FSError) {
|
||||
use created <- result.try(exceptions.resultify(fn() { do_exists(path) }))
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
pub fn write_file(path: String, data: String) -> Result(Nil, FSError) {
|
||||
use _undefined <- result.try(exceptions.resultify(fn() {
|
||||
do_write_file(path, data)
|
||||
}))
|
||||
|
||||
Ok(Nil)
|
||||
}
|
||||
|
||||
@external(javascript, "fs", "readFileSync")
|
||||
fn do_read_file(path: String) -> CanRaise(Buffer, FSError)
|
||||
|
||||
@external(javascript, "fs", "readdirSync")
|
||||
fn do_readdir(path: String) -> CanRaise(Array(Buffer), FSError)
|
||||
|
||||
@external(javascript, "fs", "mkdirSync")
|
||||
fn do_mkdir(path: String) -> CanRaise(Undefined, FSError)
|
||||
|
||||
@external(javascript, "../../ffi_fs.mjs", "mkdirP")
|
||||
fn do_mkdir_p(path: String) -> CanRaise(String, FSError)
|
||||
|
||||
@external(javascript, "fs", "existsSync")
|
||||
fn do_exists(path: String) -> CanRaise(Bool, FSError)
|
||||
|
||||
@external(javascript, "fs", "writeFileSync")
|
||||
fn do_write_file(path: String, data: String) -> CanRaise(Undefined, FSError)
|
1
src/gloss/utils/js.gleam
Normal file
1
src/gloss/utils/js.gleam
Normal file
|
@ -0,0 +1 @@
|
|||
pub type Undefined
|
33
src/gloss/utils/marked.gleam
Normal file
33
src/gloss/utils/marked.gleam
Normal file
|
@ -0,0 +1,33 @@
|
|||
import gloss/utils/object.{Object}
|
||||
|
||||
pub type Options =
|
||||
Object
|
||||
|
||||
pub fn new_options() -> Options {
|
||||
object.new()
|
||||
}
|
||||
|
||||
pub fn set_mangle(options: Options, do_mangle: Bool) -> Options {
|
||||
object.set(options, "mangle", do_mangle)
|
||||
}
|
||||
|
||||
pub fn set_header_ids(options: Options, ids: Bool) -> Options {
|
||||
object.set(options, "headerIds", ids)
|
||||
}
|
||||
|
||||
pub fn set_header_prefix(options: Options, prefix: String) -> Options {
|
||||
object.set(options, "headerPrefix", prefix)
|
||||
}
|
||||
|
||||
pub fn default_parse(content: String) -> String {
|
||||
let options =
|
||||
new_options()
|
||||
|> set_mangle(False)
|
||||
|> set_header_ids(False)
|
||||
|> set_header_prefix("")
|
||||
|
||||
parse(content, options)
|
||||
}
|
||||
|
||||
@external(javascript, "../../priv/vendor/marked.esm.mjs", "parse")
|
||||
pub fn parse(content content: String, options options: Options) -> String
|
2
src/gloss/utils/meta_url.gleam
Normal file
2
src/gloss/utils/meta_url.gleam
Normal file
|
@ -0,0 +1,2 @@
|
|||
@external(javascript, "../../ffi_meta_url.mjs", "metaURL")
|
||||
pub fn get() -> String
|
10
src/gloss/utils/object.gleam
Normal file
10
src/gloss/utils/object.gleam
Normal file
|
@ -0,0 +1,10 @@
|
|||
pub type Object
|
||||
|
||||
@external(javascript, "../../ffi_object.mjs", "create")
|
||||
pub fn new() -> Object
|
||||
|
||||
@external(javascript, "../../ffi_object.mjs", "set")
|
||||
pub fn set(object object: Object, prop prop: String, value value: a) -> Object
|
||||
|
||||
@external(javascript, "../../ffi_object.mjs", "get")
|
||||
pub fn get(object object: Object, prop prop: String) -> b
|
87
src/gloss/utils/ordered_tree.gleam
Normal file
87
src/gloss/utils/ordered_tree.gleam
Normal file
|
@ -0,0 +1,87 @@
|
|||
//// An ordered unbalanced tree.
|
||||
////
|
||||
//// Ordering of items is maintained as new items are inserted into the tree.
|
||||
//// Sort of a replacement for an ordered list. Worst case performance for
|
||||
//// insertion is O(n).
|
||||
|
||||
import gleam/order.{Eq, Gt, Lt, Order}
|
||||
|
||||
/// Item ordering for when converting the tree to a list.
|
||||
pub type ListOrdering {
|
||||
Asc
|
||||
Desc
|
||||
}
|
||||
|
||||
/// Comparator function to resolve the order of items. Must return `Lt` if the
|
||||
/// first argument is before the second, `Gt` if the opposite, or `Eq` if they
|
||||
/// are equal.
|
||||
pub type Comparator(a) =
|
||||
fn(a, a) -> Order
|
||||
|
||||
pub opaque type OrderedTree(a) {
|
||||
OrderedTree(root: Node(a), comparator: Comparator(a))
|
||||
}
|
||||
|
||||
type Node(a) {
|
||||
Empty
|
||||
Node(before: Node(a), after: Node(a), value: a)
|
||||
}
|
||||
|
||||
/// Create a new, empty tree, with the given comparator.
|
||||
pub fn new(comparator: Comparator(a)) -> OrderedTree(a) {
|
||||
OrderedTree(root: Empty, comparator: comparator)
|
||||
}
|
||||
|
||||
/// Insert a new item into the tree.
|
||||
pub fn insert(tree: OrderedTree(a), item: a) -> OrderedTree(a) {
|
||||
OrderedTree(..tree, root: do_insert(tree.root, item, tree.comparator))
|
||||
}
|
||||
|
||||
fn do_insert(node: Node(a), item: a, comparator: Comparator(a)) -> Node(a) {
|
||||
case node {
|
||||
Empty -> new_node(item)
|
||||
Node(before: before, after: after, value: value) -> {
|
||||
case comparator(value, item) {
|
||||
Lt | Eq ->
|
||||
Node(
|
||||
before: before,
|
||||
after: do_insert(after, item, comparator),
|
||||
value: value,
|
||||
)
|
||||
Gt ->
|
||||
Node(
|
||||
before: do_insert(before, item, comparator),
|
||||
after: after,
|
||||
value: value,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_node(item: a) -> Node(a) {
|
||||
Node(before: Empty, after: Empty, value: item)
|
||||
}
|
||||
|
||||
/// Get the tree as a list in the given list order.
|
||||
pub fn to_list(tree: OrderedTree(a), order: ListOrdering) -> List(a) {
|
||||
do_to_list(tree.root, [], order)
|
||||
}
|
||||
|
||||
fn do_to_list(node: Node(a), acc: List(a), order: ListOrdering) -> List(a) {
|
||||
case node {
|
||||
Empty -> acc
|
||||
Node(before: before, after: after, value: value) -> {
|
||||
case order {
|
||||
Asc -> {
|
||||
let afters = do_to_list(after, acc, order)
|
||||
do_to_list(before, [value, ..afters], order)
|
||||
}
|
||||
Desc -> {
|
||||
let befores = do_to_list(before, acc, order)
|
||||
do_to_list(after, [value, ..befores], order)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
2
src/gloss/utils/path.gleam
Normal file
2
src/gloss/utils/path.gleam
Normal file
|
@ -0,0 +1,2 @@
|
|||
@external(javascript, "path", "dirname")
|
||||
pub fn dirname(filename: String) -> String
|
9
src/gloss/utils/priv.gleam
Normal file
9
src/gloss/utils/priv.gleam
Normal file
|
@ -0,0 +1,9 @@
|
|||
import gleam/uri
|
||||
import gloss/utils/meta_url
|
||||
import gloss/utils/path
|
||||
|
||||
pub fn path() -> String {
|
||||
let assert Ok(meta_url) = uri.parse(meta_url.get())
|
||||
|
||||
path.dirname(meta_url.path) <> "/priv"
|
||||
}
|
9
src/gloss/utils/string.gleam
Normal file
9
src/gloss/utils/string.gleam
Normal file
|
@ -0,0 +1,9 @@
|
|||
import gleam/string
|
||||
|
||||
/// Split the given string at the given index
|
||||
pub fn split_at(str: String, index: Int) -> #(String, String) {
|
||||
let len = string.length(str)
|
||||
let first = string.slice(str, 0, index)
|
||||
let rest = string.slice(str, index, len - index)
|
||||
#(first, rest)
|
||||
}
|
20
src/gloss/utils/time.gleam
Normal file
20
src/gloss/utils/time.gleam
Normal file
|
@ -0,0 +1,20 @@
|
|||
import gleam/order.{Eq, Gt, Lt, Order}
|
||||
|
||||
pub type Time {
|
||||
Time(hours: Int, minutes: Int)
|
||||
}
|
||||
|
||||
/// Compare if `a` is before (lower than) than `b`.
|
||||
pub fn compare(a: Time, b: Time) -> Order {
|
||||
case a.hours, b.hours {
|
||||
a_hours, b_hours if a_hours < b_hours -> Lt
|
||||
a_hours, b_hours if a_hours > b_hours -> Gt
|
||||
_, _ -> {
|
||||
case a.minutes, b.minutes {
|
||||
a_mins, b_mins if a_mins < b_mins -> Lt
|
||||
a_mins, b_mins if a_mins > b_mins -> Gt
|
||||
_, _ -> Eq
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
15
src/gloss/utils/uniqid.gleam
Normal file
15
src/gloss/utils/uniqid.gleam
Normal file
|
@ -0,0 +1,15 @@
|
|||
pub type UniqID =
|
||||
Int
|
||||
|
||||
pub opaque type Generator {
|
||||
Generator(id: Int)
|
||||
}
|
||||
|
||||
pub fn new() -> Generator {
|
||||
Generator(0)
|
||||
}
|
||||
|
||||
pub fn get(gen: Generator) -> #(Int, Generator) {
|
||||
let new = gen.id + 1
|
||||
#(new, Generator(new))
|
||||
}
|
46
src/gloss/writer.gleam
Normal file
46
src/gloss/writer.gleam
Normal file
|
@ -0,0 +1,46 @@
|
|||
import gleam/list
|
||||
import gleam/map
|
||||
import gleam/result
|
||||
import gloss/rendering/database.{Database}
|
||||
import gloss/utils/fs
|
||||
import gloss/models/post.{Post}
|
||||
import gloss/paths/post.{PostPath} as post_paths
|
||||
|
||||
const default_output = "output"
|
||||
|
||||
pub type PostPathGenerator =
|
||||
fn(Post) -> PostPath
|
||||
|
||||
pub type Writer {
|
||||
Writer(posts: fn(Database) -> Result(Nil, WriteError))
|
||||
}
|
||||
|
||||
pub type WriteError {
|
||||
FSError(err: fs.FSError)
|
||||
}
|
||||
|
||||
pub fn default_posts(
|
||||
db: Database,
|
||||
post_to_path: fn(Post) -> PostPath,
|
||||
) -> Result(Nil, WriteError) {
|
||||
use _ <- result.try(
|
||||
list.map(
|
||||
map.values(db.posts),
|
||||
fn(post) {
|
||||
let paths = post_to_path(post.orig)
|
||||
|
||||
use _ <- result.try(fs.mkdir_p(default_output <> "/" <> paths.date_path))
|
||||
use _ <- result.try(fs.write_file(
|
||||
default_output <> "/" <> paths.date_path <> "/" <> paths.filename,
|
||||
post.content.full,
|
||||
))
|
||||
|
||||
Ok(Nil)
|
||||
},
|
||||
)
|
||||
|> result.all()
|
||||
|> result.map_error(FSError),
|
||||
)
|
||||
|
||||
Ok(Nil)
|
||||
}
|
Loading…
Reference in a new issue