This commit is contained in:
Mikko Ahlroth 2023-09-17 19:48:31 +03:00
commit b025e693d9
52 changed files with 4662 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
*.beam
*.ez
build
erl_crash.dump
/data
/static
/output

2
.tool-versions Normal file
View file

@ -0,0 +1,2 @@
nodejs 18.16.0
gleam 0.30.2

24
README.md Normal file
View 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
View 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
View 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
View 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
}

View 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>

View 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>

View 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>

View 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>

View 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

File diff suppressed because it is too large Load diff

3
src/ffi_buffer.mjs Normal file
View file

@ -0,0 +1,3 @@
export function to_string(buffer) {
return buffer.toString("utf8");
}

9
src/ffi_exceptions.mjs Normal file
View 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
View 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
View file

@ -0,0 +1,3 @@
export function metaURL() {
return import.meta.url;
}

11
src/ffi_object.mjs Normal file
View 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
View file

@ -0,0 +1,3 @@
export function fromString(str) {
return new URL(str);
}

17
src/gloss.gleam Normal file
View 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
View 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
View 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)
}),
)
}

View 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)
}

View 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
View 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
View 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
View 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
}

View 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
View 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)
}

View 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)
}

View file

@ -0,0 +1,5 @@
import gloss/rendering/post.{ContentRenderer as PostContentRenderer}
pub type ContentRenderer {
ContentRenderer(post: PostContentRenderer)
}

View 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),
)
}

View 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,
)
}

View 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))
},
),
)
}

View file

@ -0,0 +1 @@

View 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)
}

View 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)
}

View 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
}

View 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
View 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
}
}

View 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
View 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
View file

@ -0,0 +1 @@
pub type Undefined

View 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

View file

@ -0,0 +1,2 @@
@external(javascript, "../../ffi_meta_url.mjs", "metaURL")
pub fn get() -> String

View 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

View 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)
}
}
}
}
}

View file

@ -0,0 +1,2 @@
@external(javascript, "path", "dirname")
pub fn dirname(filename: String) -> String

View 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"
}

View 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)
}

View 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
}
}
}
}

View 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
View 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)
}