Files
www/generator.odin
T

588 lines
14 KiB
Odin

package www
import "core:sort"
import "base:runtime"
import "core:bufio"
import "core:c"
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:strings"
import md "vendor:commonmark"
SITE_NAME :: "PaulW.XYZ"
COMMON_STYLESHEETS :: []string {
"/assets/global.css",
"https://cdn.jsdelivr.net/npm/prismjs@1.30.0/themes/prism-tomorrow.min.css",
}
COMMON_SCRIPTS :: []string {
"https://cdn.jsdelivr.net/npm/prismjs@1.30.0/components/prism-core.min.js",
"https://cdn.jsdelivr.net/npm/prismjs@1.30.0/plugins/autoloader/prism-autoloader.min.js",
}
OUTPUT_ROOT_NAME :: "build"
markdown_paths :: []string{"notes", "posts"}
main :: proc() {
create_output_directories(markdown_paths)
copy_public_assets()
home_page := generate_sitemap()
dump_page(home_page)
sb := strings.builder_make()
process_markdown(&sb, home_page)
generate_home(&sb, home_page)
}
Page :: struct {
title: string,
pages: map[string]Page,
}
add_page :: proc(parent: ^Page, key: string, value: Page) {
parent.pages[key] = value
}
dump_page :: proc(page: Page, indent_level := 0) {
for i in 0 ..< indent_level {
fmt.print("\t")
}
fmt.println(page.title)
for name, page in page.pages {
for i in 0 ..= indent_level {
fmt.print("\t")
}
fmt.println("/", name)
dump_page(page, indent_level + 1)
}
}
generate_sitemap :: proc() -> Page {
home_page: Page = {
title = SITE_NAME,
pages = make(map[string]Page),
}
process_markdown_parent(&home_page, "notes", "Notes")
process_markdown_parent(&home_page, "posts", "Posts")
return home_page
}
process_markdown_parent :: proc(parent: ^Page, dir: string, title: string) {
page := Page {
title = title,
pages = make(map[string]Page),
}
files, os_err := os.read_directory_by_path(dir, 0, context.temp_allocator)
if os_err != nil {
fmt.eprintfln("failed to read files from directory '%s' (%s)", dir, os_err)
return
}
for file_info in files {
if len(file_info.name) < 1 || file_info.name[0] == '.' do continue
file, os_err := os.open(file_info.fullpath, {.Read})
if os_err != nil {
panic("something funny happened lol; TODO of course!!!")
}
defer os.close(file)
reader: bufio.Reader
bufio.reader_init(&reader, os.to_reader(file))
defer bufio.reader_destroy(&reader)
line := bufio.reader_read_string(&reader, '\n', context.temp_allocator) or_continue
trimmed := strings.trim(line, "\n\r \t")
if len(trimmed) < 5 || trimmed[0] != '#' || trimmed[1] != ' ' do continue
title := strings.substring_from(trimmed, 2) or_continue
ext := os.ext(file_info.name)
if ext != ".md" do continue
output_name := strings.trim_suffix(file_info.name, ext)
md_page := Page {
title = strings.clone(title),
}
add_page(&page, output_name, md_page)
}
add_page(parent, dir, page)
}
copy_public_assets :: proc() {
err := os.copy_directory_all(OUTPUT_ROOT_NAME, "public")
if err != nil {
fmt.eprintln("error copying public assets", err)
}
}
create_output_directories :: proc(dir_names: []string) {
os_err := os.make_directory(OUTPUT_ROOT_NAME)
if os_err != nil && os_err != .Exist {
fmt.eprintfln("cannot create output root directory '%s': %s", OUTPUT_ROOT_NAME, os_err)
return
}
for dir_name in dir_names {
dir_path, alloc_err := os.join_path({OUTPUT_ROOT_NAME, dir_name}, context.temp_allocator)
if alloc_err != nil {
fmt.eprintln("allocator error:", alloc_err)
continue
}
os_err = os.make_directory(dir_path)
if os_err != nil && os_err != .Exist {
fmt.eprintln("failed to create directory:", dir_path)
continue
}
}
}
remove_title :: proc(root: ^md.Node) {
heading_state: enum {
None,
Entered,
//Copied,
Exited,
} = .None
//title: string
iter := md.iter_new(root)
defer md.iter_free(iter)
for md.iter_next(iter) != .Done {
event_type := md.iter_get_event_type(iter)
node := md.iter_get_node(iter)
#partial switch node.type {
case .Heading:
level := md.node_get_heading_level(node)
if level == 1 && heading_state != .Exited {
if event_type == .Enter {
heading_state = .Entered
} else if event_type == .Exit {
heading_state = .Exited
md.node_unlink(node)
md.node_free(node)
}
}
// case .Text:
// if heading_state == .Entered {
// heading_state = .Copied
// title = strings.clone_from_cstring(md.node_get_literal(node))
// }
}
}
//return title
}
// must free
parse_json :: proc(file_path: string) -> json.Value {
json_file_content, os_err := os.read_entire_file_from_path(file_path, context.temp_allocator)
if os_err != nil {
fmt.eprintfln("failed to read files from directory '%s' (%s)", file_path, os_err)
return nil
}
json_value, json_err := json.parse(
json_file_content,
json.DEFAULT_SPECIFICATION,
true,
context.allocator,
)
if json_err != .None {
fmt.eprintfln("error parsing json file '%s' (%s)", file_path, json_err)
return nil
}
return json_value
}
generate_home :: proc(sb: ^strings.Builder, site: Page) {
strings.builder_reset(sb)
EXTERNAL_JSON_FILE :: "external.json"
external_json := parse_json(EXTERNAL_JSON_FILE)
defer json.destroy_value(external_json)
output_file_path, alloc_err := os.join_path(
{OUTPUT_ROOT_NAME, "index.html"},
context.temp_allocator,
)
if alloc_err != nil {
fmt.eprintln("allocator error:", alloc_err)
return
}
output_file, os_err2 := os.open(output_file_path, {.Write, .Create, .Trunc})
if os_err2 != nil {
fmt.eprintfln("failed to open output file '%s' (%s)", output_file_path, os_err2)
return
}
defer os.close(output_file)
{
html_begin(sb)
defer html_end(sb, {})
heading_title(sb, SITE_NAME)
container_begin(sb)
defer container_end(sb)
{
block_begin(sb)
defer block_end(sb)
obj := external_json.(json.Object)
for text, href in obj {
fmt.sbprintf(
sb,
"<a class='button blue link extern' href='%s'>%s</a>",
href.(json.String),
text,
)
}
}
{
block_begin(sb)
defer block_end(sb)
fmt.sbprintf(sb, "<h2>%s</h2>", "Navigation")
for name, page in site.pages {
fmt.sbprintf(sb, "<a class='button green link' href='%s'>%s</a>", name, page.title)
}
}
}
os.write_string(output_file, strings.to_string(sb^))
}
set_pages_to_process :: proc(page: Page, pages: ^map[string]^Page, parent_path := "") {
for name, &page in page.pages {
current_path := strings.trim(fmt.tprintf("%s/%s", parent_path, name), "/")
for path in markdown_paths {
if strings.trim(path, "/") == current_path do map_insert(pages, path, &page)
}
set_pages_to_process(page, pages, current_path)
}
}
process_markdown :: proc(sb: ^strings.Builder, site: Page) {
pages_to_process := make(map[string]^Page)
defer delete(pages_to_process)
set_pages_to_process(site, &pages_to_process)
for markdown_dir, page in pages_to_process {
markdown_dir_toc := make([dynamic][3]string) // path, title, date
defer delete(markdown_dir_toc)
defer create_toc_page(page.title, markdown_dir, &markdown_dir_toc, site)
for md_name, md_page in page.pages {
current_path := fmt.tprintf("/%s/%s", markdown_dir, md_name)
input_file_name := fmt.tprint(md_name, ".md", sep = "")
input_file_path, alloc_err := os.join_path(
{markdown_dir, input_file_name},
context.temp_allocator,
)
git_state, git_out, git_err, os_err := os.process_exec(
os.Process_Desc {
command = {
"git",
"log",
"-1",
"--date=format-local:%Y-%m-%dT%H:%M:%SZ",
"--format=%ad",
"--",
input_file_path,
},
env = {"TZ=UTC"},
},
context.temp_allocator,
)
if os_err != nil {
fmt.eprintln("failed to start git process", os_err, git_err)
return
}
date := strings.trim(string(git_out), " \t\n\r")
// @leak
append(&markdown_dir_toc, [3]string{strings.clone(current_path), md_page.title, date})
file, os_err1 := os.read_entire_file(input_file_path, context.temp_allocator)
strings.builder_reset(sb)
cmark_root := md.parse_document(
cast([^]u8)raw_data(file),
len(file),
{.Validate_UTF8, .Unsafe},
)
remove_title(cmark_root)
html := md.render_html(cmark_root, {.Unsafe})
output_file_name := fmt.tprint(md_name, ".html", sep = "")
output_file_path, alloc_err2 := os.join_path(
{OUTPUT_ROOT_NAME, markdown_dir, output_file_name},
context.temp_allocator,
)
out_file, os_err2 := os.open(output_file_path, {.Write, .Create, .Trunc})
if os_err2 != nil {
fmt.eprintfln("failed to open output file '%s' (%s)", output_file_path, os_err2)
continue
}
defer os.close(out_file)
{
html_begin(sb, md_page.title)
defer html_end(sb, site, current_path)
heading_title(sb, md_page.title)
container_begin(sb)
defer container_end(sb)
last_updated_date(sb, date)
block_begin(sb)
defer block_end(sb)
fmt.sbprint(sb, html)
}
write_len: int
write_len, os_err = os.write_strings(out_file, strings.to_string(sb^))
if os_err != nil {
fmt.eprintfln("failed to write to output file '%s' (%s)", output_file_path, os_err)
continue
}
}
}
}
create_toc_page :: proc(title, toc_path: string, toc_info: ^[dynamic][3]string, site: Page) {
_sb: strings.Builder = strings.builder_make()
sb := &_sb
defer strings.builder_destroy(sb)
toc_file_path, alloc_err := os.join_path(
{OUTPUT_ROOT_NAME, toc_path, "index.html"},
context.temp_allocator,
)
if alloc_err != nil {
fmt.eprintln("allocator error:", alloc_err)
return
}
sort_toc :: proc(a: [3]string, b: [3]string) -> int {
return -strings.compare(a[2], b[2])
}
sort.quick_sort_proc(toc_info[:], sort_toc)
fmt.println(toc_file_path)
{
html_begin(sb, title)
defer html_end(sb, site, toc_path)
heading_title(sb, title)
container_begin(sb)
defer container_end(sb)
block_begin(sb)
defer block_end(sb)
if len(toc_info) > 0 {
table_begin(sb)
defer table_end(sb)
for item in toc_info {
tr_begin(sb)
fmt.sbprintf(
sb,
"<td><a href='%s.html'>%s</a></td><td><span class='table-last-updated' data-value='%s'>aarfsdf</span></td>",
item[0],
item[1],
item[2],
)
tr_end(sb)
}
fmt.sbprint(sb,
`<style>
.table-last-updated {font-style: italic;margin-right:0}
</style>
<script>
document.querySelectorAll('.table-last-updated')
.forEach(i => {
const date = new Date(i.attributes['data-value'].value);
i.innerText = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short'}).format(date)
})
</script>`
)
} else {
fmt.sbprint(
sb,
"<div class='text center'>",
"<p>**crickets**</p>",
"<p>Nothing to list...</p>",
"<a class='link button green back' href='/'>Go Home</a>",
"</div>",
)
}
}
out_file, os_err2 := os.open(toc_file_path, {.Write, .Create, .Trunc})
if os_err2 != nil {
fmt.eprintfln("failed to open output file '%s' (%s)", toc_file_path, os_err2)
return
}
defer os.close(out_file)
write_len, os_err := os.write_strings(out_file, strings.to_string(_sb))
if os_err != nil {
fmt.eprintfln("failed to write to output file '%s' (%s)", toc_file_path, os_err)
return
}
}
last_updated_date :: proc(sb: ^strings.Builder, date: string) {
fmt.sbprint(sb, "<span class='last-updated'></span>")
fmt.sbprint(
sb,
"<script>",
"const date = new Date('",
date,
"');",
"document.querySelector('.last-updated').innerText = 'Last updated: ' + new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short'}).format(date)",
"</script>",
sep = "",
)
}
nav :: proc(sb: ^strings.Builder, current_path: string, site: Page) {
fmt.sbprint(sb, "<nav>")
current_page := site
if current_path == "/" {
fmt.sbprint(sb, SITE_NAME)
} else {
fmt.sbprintf(sb, "<a href='%s'>%s</a>", "/", SITE_NAME)
res, ok := strings.split(current_path, "/", context.temp_allocator)
temp_sb := strings.builder_make(context.temp_allocator)
for i in 1 ..< (len(res) - 1) {
current_page = current_page.pages[res[i]]
fmt.sbprintf(&temp_sb, "/%s", res[i])
fmt.sbprintf(
sb,
" / <a href='%s'>%s</a>",
strings.to_string(temp_sb),
current_page.title,
)
}
if len(res) > 0 {
fmt.sbprint(sb, " /", current_page.pages[res[len(res) - 1]].title)
}
}
fmt.sbprint(sb, "</nav>")
}
has_called_html_begin := 0
html_begin :: proc(sb: ^strings.Builder, title: string = "") {
assert(has_called_html_begin >= 0)
has_called_html_begin += 1
fmt.sbprint(sb, "<!DOCTYPE html>")
fmt.sbprint(sb, "<html lang='en-US'>")
fmt.sbprint(sb, "<head>")
fmt.sbprint(sb, "<meta charset='utf-8'>")
if title == "" do fmt.sbprintf(sb, "<title>%s</title>", SITE_NAME)
else do fmt.sbprintf(sb, "<title>%s | %s</title>", title, SITE_NAME)
for s in COMMON_STYLESHEETS {
fmt.sbprintf(sb, "<link rel='stylesheet' href='%s' />", s)
}
for s in COMMON_SCRIPTS {
fmt.sbprintf(sb, "<script type='text/javascript' src='%s'></script>", s)
}
fmt.sbprint(sb, "</head>")
fmt.sbprint(sb, "<body>")
}
html_end :: proc(sb: ^strings.Builder, site: Page, current_path := "/") {
assert(has_called_html_begin > 0)
has_called_html_begin -= 1
nav(sb, current_path, site)
fmt.sbprint(sb, "</body>")
fmt.sbprint(sb, "</html>")
}
has_called_container_begin := 0
container_begin :: proc(sb: ^strings.Builder, element := "main") {
assert(has_called_container_begin >= 0)
has_called_container_begin += 1
fmt.sbprintf(sb, "<%s class='container'>", element)
}
container_end :: proc(sb: ^strings.Builder, element := "main") {
assert(has_called_container_begin > 0)
has_called_container_begin -= 1
fmt.sbprintf(sb, "</%s>", element)
}
has_called_block_begin := 0
block_begin :: proc(sb: ^strings.Builder, element := "section") {
assert(has_called_block_begin >= 0)
has_called_block_begin += 1
fmt.sbprintf(sb, "<%s class='block'>", element)
}
block_end :: proc(sb: ^strings.Builder, element := "section") {
assert(has_called_block_begin > 0)
has_called_block_begin -= 1
fmt.sbprintf(sb, "</%s>", element)
}
heading_title :: proc(sb: ^strings.Builder, title: string) {
fmt.sbprintf(sb, "<heading class='container'><h1 class='title'>%s</h1></heading>", title)
}
has_called_table_begin := 0
table_begin :: proc(sb: ^strings.Builder) {
assert(has_called_table_begin >= 0)
has_called_table_begin += 1
fmt.sbprint(sb, "<table>")
}
table_end :: proc(sb: ^strings.Builder) {
assert(has_called_table_begin > 0)
has_called_table_begin -= 1
fmt.sbprintf(sb, "</table>")
}
has_called_tr_begin := 0
tr_begin :: proc(sb: ^strings.Builder) {
assert(has_called_tr_begin >= 0)
has_called_tr_begin += 1
fmt.sbprint(sb, "<tr>")
}
tr_end :: proc(sb: ^strings.Builder) {
assert(has_called_tr_begin > 0)
has_called_tr_begin -= 1
fmt.sbprintf(sb, "</tr>")
}