Files
thor/content.odin
T
2026-07-25 15:13:04 -04:00

259 lines
6.3 KiB
Odin

package main
import md "markdown"
import ts "treesitter"
import "core:fmt"
import "core:log"
import "core:os"
import "core:strings"
// Fields with underscores should never be set by the user.
Page :: struct {
section: string,
slug: string,
layout: string,
permalink: string,
url: string,
title: string,
description: string,
date: string,
lastmod: string,
menu: string,
body_html: string,
og: Open_Graph,
draft: bool,
is_starred: bool,
_is_index: bool `private`,
}
Pending_File :: struct {
path: string,
section: string,
slug: string,
is_index: bool,
}
// site_load_content reads the content directory and populates site.pages.
// Drafts are excluded unless .Drafts is enabled.
site_load_content :: proc(site: ^Site) {
site.pages = make([dynamic]Page, 0, 8, site_allocator(site))
// Phase 0: Enumerate content files
pending := make([dynamic]Pending_File, 0, 16, context.temp_allocator)
scan_content_files(site.content_dir, "", &pending)
// Phase 1: Pre-scan for code fence languages
// Phase 2: Parallel grammar preload
if .Highlight in site.markdown_extensions {
languages := collect_languages(pending[:])
ts.preload_grammars(languages)
}
// Phase 3: Load pages (grammars already cached)
for file in pending {
page, ok := load_page(file.path, file.section, file.slug, file.is_index, site.markdown_extensions)
if ok && (!page.draft || .Drafts in site.features) {
append(&site.pages, page)
}
}
for &page in site.pages {
page.url = fmt.tprintf("%s%s", site.base_url, page.permalink)
}
}
// scan_content_files walks the content directory and collects Pending_File
// entries. At the root level (section=""), directories are treated as
// sections. Within a section, directories are treated as leaf bundles.
scan_content_files :: proc(dir: string, section: string, pending: ^[dynamic]Pending_File) {
entries, err := os.read_all_directory_by_path(dir, context.allocator)
if err != nil {
log.warnf("cannot read %s: %v", dir, err)
return
}
defer os.file_info_slice_delete(entries, context.allocator)
for entry in entries {
switch entry.type {
case .Regular:
if !is_content_file(entry.name) {
continue
}
filename := strip_extension(entry.name)
is_idx := filename == "index"
slug := is_idx ? "" : filename
append(pending, Pending_File {
path = strings.clone(entry.fullpath, context.temp_allocator),
section = section,
slug = slug,
is_index = is_idx,
})
case .Directory:
if section == "" {
scan_content_files(entry.fullpath, entry.name, pending)
} else {
index_path := fmt.tprintf("%s/index.html", entry.fullpath)
if !os.exists(index_path) {
index_path = fmt.tprintf("%s/index.md", entry.fullpath)
}
if os.exists(index_path) {
append(pending, Pending_File {
path = strings.clone(index_path, context.temp_allocator),
section = section,
slug = entry.name,
is_index = false,
})
}
}
case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
}
}
}
// collect_languages scans .md files for code fence language identifiers
// (```lang or ~~~lang) and returns the unique set.
collect_languages :: proc(files: []Pending_File) -> []string {
set := make(map[string]bool, context.temp_allocator)
for file in files {
if !strings.has_suffix(file.path, ".md") {
continue
}
data, err := os.read_entire_file_from_path(file.path, context.temp_allocator)
if err != nil {
continue
}
content := string(data)
pos := 0
for pos < len(content) {
newline := strings.index_byte(content[pos:], '\n')
line_end := pos + newline if newline >= 0 else len(content)
line := content[pos:line_end]
i := 0
for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
i += 1
}
if i + 3 <= len(line) && (line[i] == '`' && line[i+1] == '`' && line[i+2] == '`') ||
(i + 3 <= len(line) && line[i] == '~' && line[i+1] == '~' && line[i+2] == '~') {
fence_char := line[i]
j := i + 3
for j < len(line) && line[j] == fence_char {
j += 1
}
for j < len(line) && (line[j] == ' ' || line[j] == '\t') {
j += 1
}
lang_start := j
for j < len(line) {
c := line[j]
if c == ' ' || c == '\t' || c == '\r' || c == '\n' {
break
}
j += 1
}
if j > lang_start {
set[line[lang_start:j]] = true
}
}
pos = line_end + 1
}
}
result := make([dynamic]string, 0, len(set), context.temp_allocator)
for lang in set {
append(&result, lang)
}
return result[:]
}
infer_layout :: proc(section: string, is_index: bool) -> string {
if section == "" && is_index {
return "home"
}
if is_index {
return fmt.tprintf("%s_index", section)
}
if section != "" {
if len(section) > 1 && section[len(section) - 1] == 's' {
return section[:len(section) - 1]
}
return section
}
return "page"
}
load_page :: proc(
file_path: string,
section: string,
slug: string,
is_index: bool,
ext: bit_set[md.Extension],
) -> (
page: Page,
ok: bool,
) {
data, err := os.read_entire_file_from_path(file_path, context.allocator)
if err != nil {
log.warnf("cannot read %s: %v", file_path, err)
return
}
content := string(data)
fm, body, parsed := parse_frontmatter(content)
if !parsed {
body = strings.trim_left(content, " \t\r\n")
}
page.section = section
page.slug = slug
page._is_index = is_index
page.title = fm.title
page.description = fm.description
page.date = fm.date
page.lastmod = fm.lastmod
page.draft = fm.draft
page.is_starred = fm.isStarred
page.menu = fm.menu
page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index)
page.og = fm.og
if strings.has_suffix(file_path, ".html") {
page.body_html = strings.clone(body)
} else {
page.body_html = md.process(body, ext, file_path)
}
if section == "" && is_index {
page.permalink = "/"
} else if is_index {
page.permalink = fmt.aprintf("/%s/", section)
} else if section == "" {
page.permalink = fmt.aprintf("/%s/", slug)
} else {
page.permalink = fmt.aprintf("/%s/%s/", section, slug)
}
ok = true
return
}
is_content_file :: proc(name: string) -> bool {
return strings.has_suffix(name, ".md") || strings.has_suffix(name, ".html")
}
strip_extension :: proc(name: string) -> string {
dot := strings.last_index(name, ".")
if dot < 0 {
return name
}
return name[:dot]
}