Compare commits

...

5 Commits

Author SHA1 Message Date
Spencer Brower 085811b312 refactor: Moved markdown code to a separate packge. 2026-07-17 14:40:29 -04:00
Spencer Brower 618fabf0e4 refactor: Moved treesitter to its own package. 2026-07-17 14:10:21 -04:00
Spencer Brower dfe863e293 fix: Added -layout flag to set layouts dir. 2026-07-17 13:42:44 -04:00
Spencer Brower 528bef3941 feat: Added Unified file system / default layout mounts. 2026-07-17 13:31:00 -04:00
Spencer Brower fdd9ef4d46 docs: Added README.md. 2026-07-17 12:59:51 -04:00
27 changed files with 1055 additions and 852 deletions
+40
View File
@@ -0,0 +1,40 @@
# Thor
[TOC]
Thor is a simple Static Sire Generator designed for personal blogs and other small websites.
Its core principals are simplicity and minimal configuration, so you can get started as quickly as possible.
It is based on Hugo, and gingerbill's SSG. Templating is done with (extended?) Mustache templates.
## What it does
- Syntax Highlighting server-side (with Tree Sitter) or client-side with highlight.js
- Mustache Templating
- OpenGraph Tags
- Menus (WIP)
- Extended Markdown ([See below](#extended-markdown))
- Basic (whitespace) minification.
## What it doesn't do
- Internationalization
- Pagination (Yet)
- Themes
- Union File System (Yet)
- Image Manipulation
- TailwindCSS integration
## Getting Started
Run `thor`
Check out `public/index.html`
Then follow [The Guide]()
For a more complete setup, run `thor new site`.
## Extended Markdown
- Emoji expansion
- margin style footnotes
- Guthub style alerts
- [and more]
+16 -8
View File
@@ -1,10 +1,13 @@
- README.md - README.md
- [ ] "Zero is beautiful" - [ ] "Zero is beautiful"
- [ ] Content-hash fingerprinting for CSS and JS cache busting - [ ] Content-hash fingerprinting for CSS and JS cache busting
- [ ] Clean up the default layouts
- [ ] Performance - [ ] Performance
- [ ] See if we can disable bounds checks in `write_indented` and elsewhere. - [ ] See if we can disable bounds checks in `write_indented` and elsewhere.
- [ ] Instead of loading the site fresh each time in watch mode, create a - [ ] Instead of loading the site fresh each time in watch mode, create a
`reload_site` proc, that just updates changed resources. `reload_site` proc, that just updates changed resources.
- [ ] Only publish referenced assets.
- [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely
- [ ] mustache data keys for opengraph, etc. - [ ] mustache data keys for opengraph, etc.
- [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md - [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin - [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
@@ -34,7 +37,7 @@
- [ ] event based - [ ] event based
- [ ] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode - [ ] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode
- [ ] Rename `{{#is_post}}` to `{{#is_article}}` in templates and data model — currently hardcoded to posts section, should use `is_article` (any page with a section) instead. - [ ] Rename `{{#is_post}}` to `{{#is_article}}` in templates and data model — currently hardcoded to posts section, should use `is_article` (any page with a section) instead.
- [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely - [ ] Mount content in VFS
- [ ] commands - [ ] commands
- [ ] `build` alias of default - [ ] `build` alias of default
- [ ] `init` set up new project - [ ] `init` set up new project
@@ -43,16 +46,21 @@
- [ ] Import/export packages. Hugo, jekyll, WordPress, etc. - [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
- [ ] Review every file in thor - [ ] Review every file in thor
- [ ] Review assets.odin - [ ] Review assets.odin
- [ ] Review alerts.odin
- [ ] Review content.odin - [ ] Review content.odin
- [ ] Review emoji.odin - [ ] Review defaults.odin
- [ ] Review feed.odin - [ ] Review feed.odin
- [ ] Review footnotes.odin
- [ ] Review frontmatter.odin - [ ] Review frontmatter.odin
- [ ] Review highlight.odin
- [ ] Review main.odin - [ ] Review main.odin
- [ ] Review minifiy.odin - [ ] Review minify.odin
- [ ] Review `markdown/`
- [ ] Review alerts.odin
- [ ] Review emoji.odin
- [ ] Review footnotes.odin
- [ ] Review footnotes_test.odin
- [ ] Review highlight.odin
- [ ] Review markdown.odin
- [ ] Review sectionate.odin
- [ ] Review render.odin - [ ] Review render.odin
- [ ] Review sectionate.odin
- [x] Review site.odin - [x] Review site.odin
- [ ] Review tree_sitter.odin - [ ] Review treesitter/treesitter.odin
- [ ] Review vfs.odin
+25 -47
View File
@@ -5,57 +5,35 @@ import "core:log"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
// copy_assets_dir recursively copies files from assets_dir to output_dir. copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature]) {
// .css files are minified when .Minify is enabled; all other files are copied verbatim. for virtual_path, entry in vfs.files {
// Silently skips if assets_dir doesn't exist. if !strings.has_prefix(virtual_path, "assets/") {
copy_assets_dir :: proc(assets_dir: string, output_dir: string, features: bit_set[Feature]) { continue
if !os.exists(assets_dir) { }
return
}
copy_assets_recursive(assets_dir, "", output_dir, features)
}
copy_assets_recursive :: proc( rel := virtual_path[len("assets/"):]
current: string, dest := fmt.tprintf("%s/%s", output_dir, rel)
rel_prefix: string,
output_dir: string,
features: bit_set[Feature],
) {
entries, err := os.read_all_directory_by_path(current, context.allocator)
if err != nil {
log.warnf("thor: cannot read %s: %v", current, err)
return
}
defer os.file_info_slice_delete(entries, context.allocator)
for entry in entries { if idx := strings.last_index(dest, "/"); idx >= 0 {
rel := rel_prefix == "" ? entry.name : fmt.tprintf("%s/%s", rel_prefix, entry.name) if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist {
switch entry.type { log.warnf("thor: cannot create %s: %v", dest[:idx], err)
case .Regular: continue
dest := fmt.tprintf("%s/%s", output_dir, rel)
if idx := strings.last_index(dest, "/"); idx >= 0 {
if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist {
log.warnf("thor: cannot create %s: %v", dest[:idx], err)
continue
}
} }
if .Minify in features && strings.has_suffix(entry.name, ".css") { }
data, read_err := os.read_entire_file_from_path(entry.fullpath, context.allocator)
if read_err != nil { if .Minify in features && strings.has_suffix(rel, ".css") {
log.warnf("thor: cannot read %s: %v", entry.fullpath, read_err) data, ok := vfs_get(vfs, virtual_path)
continue if ok {
} write_file(dest, minify_css(string(data)))
minified := minify_css(string(data)) }
write_file(dest, minified) } else if entry.data != nil {
} else { if err := os.write_entire_file(dest, entry.data); err != nil {
if err := os.copy_file(dest, entry.fullpath); err != nil { log.warnf("thor: cannot write %s: %v", dest, err)
log.warnf("thor: cannot copy %s: %v", entry.fullpath, err) }
} } else {
if err := os.copy_file(dest, entry.fs_path); err != nil {
log.warnf("thor: cannot copy %s: %v", entry.fs_path, err)
} }
case .Directory:
copy_assets_recursive(entry.fullpath, rel, output_dir, features)
case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
} }
} }
} }
+3 -25
View File
@@ -1,6 +1,6 @@
package main package main
import cm "vendor:commonmark" import md "markdown"
import "core:fmt" import "core:fmt"
import "core:log" import "core:log"
@@ -101,7 +101,7 @@ load_page :: proc(
section: string, section: string,
slug: string, slug: string,
is_index: bool, is_index: bool,
ext: bit_set[Markdown_Extension], ext: bit_set[md.Extension],
) -> ( ) -> (
page: Page, page: Page,
ok: bool, ok: bool,
@@ -132,29 +132,7 @@ load_page :: proc(
if strings.has_suffix(file_path, ".html") { if strings.has_suffix(file_path, ".html") {
page.body_html = strings.clone(body) page.body_html = strings.clone(body)
} else { } else {
sn_defs := make(map[string]string) page.body_html = md.process(body, ext, file_path)
mn_defs := make(map[string]string)
clean_body := body
if .Sidenotes in ext {
clean_body, sn_defs, mn_defs = strip_definitions(body)
}
html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
if .Emoji in ext {
html = expand_emoji(html)
}
if .Sidenotes in ext {
html = inject_notes(html, sn_defs, mn_defs)
}
if .Alerts in ext {
html = inject_alerts(html)
}
if .Highlight in ext {
html = highlight_code(html, file_path)
}
if .Sections in ext {
html = wrap_sections(html)
}
page.body_html = html
} }
if section == "" && is_index { if section == "" && is_index {
+6
View File
@@ -0,0 +1,6 @@
package main
import "core:os"
DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults"
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{title}}</title>
{{> head}}
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
{{> nav}}{{$content}}{{/content}}
{{> footer}}
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
{{<base}}
{{$content}}
<main>
<header>
{{&body}}
</header>
<ul>
{{#list_pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
</li>
{{/list_pages}}
</ul>
</main>
{{/content}}
{{/base}}
+11
View File
@@ -0,0 +1,11 @@
{{<base}}
{{$content}}
<main>
<article>
<h1>{{page_title}}</h1>
{{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time>
{{/date_iso}} {{&body}}
</article>
</main>
{{/content}}
{{/base}}
+3
View File
@@ -0,0 +1,3 @@
<footer>
<p>&copy; {{now.year}} {{author}}</p>
</footer>
+9
View File
@@ -0,0 +1,9 @@
<meta property="og:url" content="{{og_url}}">
<meta property="og:site_name" content="{{og_site_name}}">
<meta property="og:title" content="{{og_title}}">
<meta property="og:description" content="{{og_description}}">
<meta property="og:locale" content="en_us">
<meta property="og:type" content="{{og_type}}">
{{#is_article}}<meta property="article:section" content="{{og_section}}">
<meta property="article:published_time" content="{{og_published}}">
{{/is_article}}<meta property="og:image" content="{{og_image}}">
+7
View File
@@ -0,0 +1,7 @@
<header>
<nav>
<ul>
<li><a href="/">{{title}}</a></li>
</ul>
</nav>
</header>
+19
View File
@@ -0,0 +1,19 @@
{{<base}}
{{$content}}
<main>
<h1>{{page_title}}</h1>
{{&body}}
{{#year_sections}}
<section>
<h2>{{year}}</h2>
<ul>
{{#posts}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
</li>
{{/posts}}
</ul>
</section>
{{/year_sections}}
</main>
{{/content}}
{{/base}}
-476
View File
@@ -1,476 +0,0 @@
package main
import "core:fmt"
import "core:log"
import "core:os"
import "core:strings"
Grammar_Cache :: struct {
language: TSLanguage,
parser: TSParser,
query: TSQuery,
query_failed: bool,
}
Get_Language_Proc :: #type proc() -> TSLanguage
grammar_cache: map[string]^Grammar_Cache
builtin_language :: proc(lang: string) -> (language: TSLanguage, ok: bool) {
switch lang {
case "html":
language = tree_sitter_html()
ok = true
case "css":
language = tree_sitter_css()
ok = true
}
return
}
ensure_parser :: proc(lang: string) -> ^Grammar_Cache {
if grammar_cache == nil {
grammar_cache = make(map[string]^Grammar_Cache)
}
if cached, ok := grammar_cache[lang]; ok {
return cached
}
grammar_cache[lang] = nil
language: TSLanguage
if builtin, ok := builtin_language(lang); ok {
language = builtin
} else {
if GRAPHS_PATH == "" {
log.warnf("highlight: no grammars path set, skipping %s", lang)
return nil
}
so_path := fmt.tprintf("%s/%s.so", GRAPHS_PATH, lang)
so_c := strings.clone_to_cstring(so_path)
defer delete(so_c)
handle := dlopen(so_c, RTLD_LAZY)
if handle == nil {
log.warnf("highlight: cannot load grammar %s (%s)", lang, so_path)
return nil
}
sym_name := fmt.tprintf("tree_sitter_%s", lang)
sym_c := strings.clone_to_cstring(sym_name)
defer delete(sym_c)
sym := dlsym(handle, sym_c)
if sym == nil {
log.errorf("highlight: cannot find symbol %s in %s", sym_name, so_path)
return nil
}
get_language := transmute(Get_Language_Proc)(sym)
language = get_language()
}
parser := ts_parser_new()
if parser == nil {
log.errorf("highlight: cannot create parser for %s", lang)
return nil
}
if !ts_parser_set_language(parser, language) {
log.errorf("highlight: ABI mismatch for %s grammar", lang)
ts_parser_delete(parser)
return nil
}
gc := new(Grammar_Cache)
gc.language = language
gc.parser = parser
grammar_cache[lang] = gc
return gc
}
load_grammar :: proc(lang: string) -> ^Grammar_Cache {
gc := ensure_parser(lang)
if gc == nil {
return nil
}
if gc.query != nil {
return gc
}
if gc.query_failed {
return nil
}
if QUERIES_PATH == "" {
log.warnf("highlight: no queries path set, skipping %s", lang)
gc.query_failed = true
return nil
}
query_path := fmt.tprintf("%s/%s/highlights.scm", QUERIES_PATH, lang)
query_src, err := os.read_entire_file_from_path(query_path, context.allocator)
if err != nil {
log.warnf("highlight: cannot load query %s", query_path)
gc.query_failed = true
return nil
}
query_str := string(query_src)
query_c := strings.clone_to_cstring(query_str)
defer delete(query_c)
err_offset: u32
err_type: TSQueryError
query := ts_query_new(
gc.language,
query_c,
u32(len(query_src)),
&err_offset,
&err_type,
)
if query == nil {
tok := extract_query_token(query_src, err_offset)
cause := fmt.tprintf("query error at byte %d (type %v)", err_offset, err_type)
#partial switch err_type {
case .NodeType:
if tok != "" {
cause = fmt.tprintf("query references unknown node type '%s' (byte %d); the grammar (.so) and query (.scm) are likely from different tree-sitter-%s versions", tok, err_offset, lang)
} else {
cause = fmt.tprintf("query references an unknown node type at byte %d; the grammar (.so) and query (.scm) are likely from different tree-sitter-%s versions", err_offset, lang)
}
case .Field:
cause = fmt.tprintf("query references unknown field '%s' at byte %d", tok, err_offset)
case .Capture:
cause = fmt.tprintf("query uses an invalid capture '%s' at byte %d", tok, err_offset)
case .Syntax:
cause = fmt.tprintf("query has a syntax error at byte %d", err_offset)
case .Structure:
cause = fmt.tprintf("query has an illegal pattern structure at byte %d", err_offset)
case .Language:
cause = "grammar language is null (broken grammar .so)"
}
log.errorf("highlight: %s query failed: %s", lang, cause)
_, is_builtin := builtin_language(lang)
if !is_builtin {
so_path := fmt.tprintf("%s/%s.so", GRAPHS_PATH, lang)
gram_v := helix_version_from_path(so_path)
query_v := helix_version_from_path(query_path)
gram_note := "(version unknown)"
if gram_v != "" do gram_note = fmt.tprintf("helix %s", gram_v)
query_note := "(version unknown)"
if query_v != "" do query_note = fmt.tprintf("helix %s", query_v)
log.errorf(" grammar: %s [%s]", so_path, gram_note)
log.errorf(" query: %s [%s]", query_path, query_note)
if gram_v != "" && query_v != "" && gram_v != query_v {
log.errorf(" >> helix VERSION MISMATCH: grammar %s vs query %s", gram_v, query_v)
}
}
gc.query_failed = true
return nil
}
gc.query = query
return gc
}
extract_query_token :: proc(src: []byte, offset: u32) -> string {
end := offset
for int(end) < len(src) {
c := src[end]
is_ident := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.'
if !is_ident do break
end += 1
}
if end <= offset do return ""
return string(src[offset:end])
}
helix_version_from_path :: proc(path: string) -> string {
tag := "-helix-"
idx := strings.index(path, tag)
if idx < 0 do return ""
start := idx + len(tag)
end := start
for end < len(path) {
c := path[end]
if !((c >= '0' && c <= '9') || c == '.') do break
end += 1
}
if end <= start do return ""
return path[start:end]
}
Capture :: struct {
start: u32,
end: u32,
name: string,
}
find_first_error_line :: proc(root: TSNode) -> int {
if ts_node_is_error(root) {
return int(ts_node_start_point(root).row) + 1
}
for i in 0..<ts_node_child_count(root) {
child := ts_node_child(root, u32(i))
if ts_node_has_error(child) {
line := find_first_error_line(child)
if line > 0 {
return line
}
}
}
return 0
}
capture_name_to_css :: proc(name: string) -> string {
sb := strings.builder_make()
seg := strings.builder_make()
first := true
for i in 0..<len(name) {
if name[i] == '.' {
if !first do strings.write_byte(&sb, ' ')
first = false
strings.write_string(&sb, "hl-")
strings.write_string(&sb, strings.to_string(seg))
strings.write_byte(&seg, '-')
} else {
strings.write_byte(&seg, name[i])
}
}
if !first do strings.write_byte(&sb, ' ')
strings.write_string(&sb, "hl-")
strings.write_string(&sb, strings.to_string(seg))
return strings.to_string(sb)
}
escape_html :: proc(s: string) -> string {
sb := strings.builder_make()
defer strings.builder_destroy(&sb)
start := 0
for i in 0..<len(s) {
switch s[i] {
case '&':
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, "&amp;")
start = i + 1
case '<':
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, "&lt;")
start = i + 1
case '>':
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, "&gt;")
start = i + 1
case '"':
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, "&quot;")
start = i + 1
}
}
if start == 0 do return s
if start < len(s) do strings.write_string(&sb, s[start:])
return strings.to_string(sb)
}
unescape_html :: proc(s: string) -> string {
sb := strings.builder_make()
defer strings.builder_destroy(&sb)
start := 0
for i in 0..<len(s) {
if s[i] != '&' do continue
semi := strings.index(s[i:], ";")
if semi < 0 do break
entity := s[i : i + semi + 1]
replacement := ""
switch entity {
case "&amp;": replacement = "&"
case "&lt;": replacement = "<"
case "&gt;": replacement = ">"
case "&quot;": replacement = "\""
case "&#39;", "&apos;": replacement = "'"
case: continue
}
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, replacement)
start = i + semi + 1
}
if start == 0 do return s
if start < len(s) do strings.write_string(&sb, s[start:])
return strings.to_string(sb)
}
highlight_block :: proc(code: string, lang: string, file_path: string) -> string {
gc := load_grammar(lang)
if gc == nil {
return code
}
raw_code := unescape_html(code)
raw_c := strings.clone_to_cstring(raw_code)
defer delete(raw_c)
tree := ts_parser_parse_string(gc.parser, nil, raw_c, u32(len(raw_code)))
if tree == nil {
return code
}
defer ts_tree_delete(tree)
root := ts_tree_root_node(tree)
if ts_node_has_error(root) {
line := find_first_error_line(root)
if line > 0 {
log.warnf("highlight: syntax errors in %s code block at line %d (%s)", lang, line, file_path)
} else {
log.warnf("highlight: syntax errors in %s code block (%s)", lang, file_path)
}
}
cursor := ts_query_cursor_new()
if cursor == nil {
return code
}
defer ts_query_cursor_delete(cursor)
ts_query_cursor_exec(cursor, gc.query, root)
captures: [dynamic]Capture
defer delete(captures)
match: TSQueryMatch
capture_idx: u32
for ts_query_cursor_next_capture(cursor, &match, &capture_idx) {
if capture_idx >= u32(match.capture_count) {
continue
}
cap := match.captures[capture_idx]
name_len: u32
name_c := ts_query_capture_name_for_id(gc.query, cap.index, &name_len)
if name_c == nil {
continue
}
name_full := string(name_c)
name := name_full
if len(name_full) > int(name_len) {
name = name_full[:int(name_len)]
}
append(&captures, Capture{
start = ts_node_start_byte(cap.node),
end = ts_node_end_byte(cap.node),
name = name,
})
}
if len(captures) == 0 {
return code
}
sb := strings.builder_make()
last_pos: u32 = 0
stack: [dynamic]Capture
defer delete(stack)
for cap in captures {
for len(stack) > 0 {
top := stack[len(stack) - 1]
if top.end <= cap.start {
if top.end > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:top.end]))
}
strings.write_string(&sb, "</span>")
last_pos = top.end
pop(&stack)
} else {
break
}
}
if cap.start > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:cap.start]))
last_pos = cap.start
}
css_class := capture_name_to_css(cap.name)
strings.write_string(&sb, fmt.tprintf("<span class=\"%s\">", css_class))
append(&stack, cap)
}
for len(stack) > 0 {
top := pop(&stack)
if top.end > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:top.end]))
}
strings.write_string(&sb, "</span>")
last_pos = top.end
}
if int(last_pos) < len(raw_code) {
strings.write_string(&sb, escape_html(raw_code[last_pos:]))
}
return strings.to_string(sb)
}
highlight_code :: proc(html: string, file_path: string) -> string {
PREFIX :: `<pre><code class="language-`
CODE_END :: `</code></pre>`
sb := strings.builder_make()
defer strings.builder_destroy(&sb)
pos := 0
found := false
for {
rel := strings.index(html[pos:], PREFIX)
if rel < 0 {
break
}
found = true
idx := pos + rel
if idx > pos {
strings.write_string(&sb, html[pos:idx])
}
lang_start := idx + len(PREFIX)
lang_end_rel := strings.index(html[lang_start:], `"`)
if lang_end_rel < 0 {
break
}
lang_end := lang_start + lang_end_rel
lang := html[lang_start:lang_end]
code_start := lang_end + 1
if code_start < len(html) && html[code_start] == '>' {
code_start += 1
} else {
pos = lang_end
continue
}
end_rel := strings.index(html[code_start:], CODE_END)
if end_rel < 0 {
break
}
end_idx := code_start + end_rel
code := html[code_start:end_idx]
highlighted := highlight_block(code, lang, file_path)
strings.write_string(&sb, fmt.tprintf(`<pre><code class="language-%s">%s</code></pre>`, lang, highlighted))
pos = end_idx + len(CODE_END)
}
if pos < len(html) && found {
strings.write_string(&sb, html[pos:])
}
if !found {
return html
}
return strings.to_string(sb)
}
+2 -3
View File
@@ -44,13 +44,12 @@ main :: proc() {
defer destroy_site(&site) defer destroy_site(&site)
// TODO: Make it so this isn't necessary // TODO: Make it so this isn't necessary
context.allocator = site_allocator(&site) context.allocator = site_allocator(&site)
build_vfs(&site)
site_load_content(&site) site_load_content(&site)
render_site(&site) render_site(&site)
if !(.Watch in site.features) { (.Watch in site.features) or_break
break
}
time.sleep(5 * time.Second) time.sleep(5 * time.Second)
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
#+feature dynamic-literals #+feature dynamic-literals
package main package markdown
import "core:fmt" import "core:fmt"
import "core:strings" import "core:strings"
+1 -1
View File
@@ -1,5 +1,5 @@
#+feature dynamic-literals #+feature dynamic-literals
package main package markdown
import "core:strings" import "core:strings"
+1 -1
View File
@@ -1,4 +1,4 @@
package main package markdown
import cm "vendor:commonmark" import cm "vendor:commonmark"
@@ -1,6 +1,6 @@
#+feature dynamic-literals #+feature dynamic-literals
#+test #+test
package main package markdown
import "core:strings" import "core:strings"
import "core:testing" import "core:testing"
+282
View File
@@ -0,0 +1,282 @@
package markdown
import ts "../treesitter"
import "core:fmt"
import "core:log"
import "core:strings"
Capture :: struct {
start: u32,
end: u32,
name: string,
}
find_first_error_line :: proc(root: ts.Node) -> int {
if ts.node_is_error(root) {
return int(ts.node_start_point(root).row) + 1
}
for i in 0..<ts.node_child_count(root) {
child := ts.node_child(root, u32(i))
if ts.node_has_error(child) {
line := find_first_error_line(child)
if line > 0 {
return line
}
}
}
return 0
}
capture_name_to_css :: proc(name: string) -> string {
sb := strings.builder_make()
seg := strings.builder_make()
first := true
for i in 0..<len(name) {
if name[i] == '.' {
if !first do strings.write_byte(&sb, ' ')
first = false
strings.write_string(&sb, "hl-")
strings.write_string(&sb, strings.to_string(seg))
strings.write_byte(&seg, '-')
} else {
strings.write_byte(&seg, name[i])
}
}
if !first do strings.write_byte(&sb, ' ')
strings.write_string(&sb, "hl-")
strings.write_string(&sb, strings.to_string(seg))
return strings.to_string(sb)
}
escape_html :: proc(s: string) -> string {
sb := strings.builder_make()
defer strings.builder_destroy(&sb)
start := 0
for i in 0..<len(s) {
switch s[i] {
case '&':
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, "&amp;")
start = i + 1
case '<':
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, "&lt;")
start = i + 1
case '>':
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, "&gt;")
start = i + 1
case '"':
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, "&quot;")
start = i + 1
}
}
if start == 0 do return s
if start < len(s) do strings.write_string(&sb, s[start:])
return strings.to_string(sb)
}
unescape_html :: proc(s: string) -> string {
sb := strings.builder_make()
defer strings.builder_destroy(&sb)
start := 0
for i in 0..<len(s) {
if s[i] != '&' do continue
semi := strings.index(s[i:], ";")
if semi < 0 do break
entity := s[i : i + semi + 1]
replacement := ""
switch entity {
case "&amp;": replacement = "&"
case "&lt;": replacement = "<"
case "&gt;": replacement = ">"
case "&quot;": replacement = "\""
case "&#39;", "&apos;": replacement = "'"
case: continue
}
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, replacement)
start = i + semi + 1
}
if start == 0 do return s
if start < len(s) do strings.write_string(&sb, s[start:])
return strings.to_string(sb)
}
highlight_block :: proc(code: string, lang: string, file_path: string) -> string {
gc := ts.load_grammar(lang)
if gc == nil {
return code
}
raw_code := unescape_html(code)
raw_c := strings.clone_to_cstring(raw_code)
defer delete(raw_c)
tree := ts.parser_parse_string(gc.parser, nil, raw_c, u32(len(raw_code)))
if tree == nil {
return code
}
defer ts.tree_delete(tree)
root := ts.tree_root_node(tree)
if ts.node_has_error(root) {
line := find_first_error_line(root)
if line > 0 {
log.warnf("highlight: syntax errors in %s code block at line %d (%s)", lang, line, file_path)
} else {
log.warnf("highlight: syntax errors in %s code block (%s)", lang, file_path)
}
}
cursor := ts.query_cursor_new()
if cursor == nil {
return code
}
defer ts.query_cursor_delete(cursor)
ts.query_cursor_exec(cursor, gc.query, root)
captures: [dynamic]Capture
defer delete(captures)
match: ts.Query_Match
capture_idx: u32
for ts.query_cursor_next_capture(cursor, &match, &capture_idx) {
if capture_idx >= u32(match.capture_count) {
continue
}
cap := match.captures[capture_idx]
name_len: u32
name_c := ts.query_capture_name_for_id(gc.query, cap.index, &name_len)
if name_c == nil {
continue
}
name_full := string(name_c)
name := name_full
if len(name_full) > int(name_len) {
name = name_full[:int(name_len)]
}
append(&captures, Capture{
start = ts.node_start_byte(cap.node),
end = ts.node_end_byte(cap.node),
name = name,
})
}
if len(captures) == 0 {
return code
}
sb := strings.builder_make()
last_pos: u32 = 0
stack: [dynamic]Capture
defer delete(stack)
for cap in captures {
for len(stack) > 0 {
top := stack[len(stack) - 1]
if top.end <= cap.start {
if top.end > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:top.end]))
}
strings.write_string(&sb, "</span>")
last_pos = top.end
pop(&stack)
} else {
break
}
}
if cap.start > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:cap.start]))
last_pos = cap.start
}
css_class := capture_name_to_css(cap.name)
strings.write_string(&sb, fmt.tprintf("<span class=\"%s\">", css_class))
append(&stack, cap)
}
for len(stack) > 0 {
top := pop(&stack)
if top.end > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:top.end]))
}
strings.write_string(&sb, "</span>")
last_pos = top.end
}
if int(last_pos) < len(raw_code) {
strings.write_string(&sb, escape_html(raw_code[last_pos:]))
}
return strings.to_string(sb)
}
highlight_code :: proc(html: string, file_path: string) -> string {
PREFIX :: `<pre><code class="language-`
CODE_END :: `</code></pre>`
sb := strings.builder_make()
defer strings.builder_destroy(&sb)
pos := 0
found := false
for {
rel := strings.index(html[pos:], PREFIX)
if rel < 0 {
break
}
found = true
idx := pos + rel
if idx > pos {
strings.write_string(&sb, html[pos:idx])
}
lang_start := idx + len(PREFIX)
lang_end_rel := strings.index(html[lang_start:], `"`)
if lang_end_rel < 0 {
break
}
lang_end := lang_start + lang_end_rel
lang := html[lang_start:lang_end]
code_start := lang_end + 1
if code_start < len(html) && html[code_start] == '>' {
code_start += 1
} else {
pos = lang_end
continue
}
end_rel := strings.index(html[code_start:], CODE_END)
if end_rel < 0 {
break
}
end_idx := code_start + end_rel
code := html[code_start:end_idx]
highlighted := highlight_block(code, lang, file_path)
strings.write_string(&sb, fmt.tprintf(`<pre><code class="language-%s">%s</code></pre>`, lang, highlighted))
pos = end_idx + len(CODE_END)
}
if pos < len(html) && found {
strings.write_string(&sb, html[pos:])
}
if !found {
return html
}
return strings.to_string(sb)
}
+83
View File
@@ -0,0 +1,83 @@
package markdown
import cm "vendor:commonmark"
import "core:encoding/json"
import "core:strings"
Extension :: enum {
Emoji,
Sidenotes,
Alerts,
Highlight,
Sections,
}
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts}
process :: proc(body: string, ext: bit_set[Extension], file_path: string) -> string {
side_notes := make(map[string]string)
margin_notes := make(map[string]string)
clean_body := body
if .Sidenotes in ext {
clean_body, side_notes, margin_notes = strip_definitions(body)
}
html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
if .Emoji in ext {
html = expand_emoji(html)
}
if .Sidenotes in ext {
html = inject_notes(html, side_notes, margin_notes)
}
if .Alerts in ext {
html = inject_alerts(html)
}
if .Highlight in ext {
html = highlight_code(html, file_path)
}
if .Sections in ext {
html = wrap_sections(html)
}
return html
}
// Convert a ',' separated list of case-insensitive extension names to a bit set.
parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) {
for part in strings.split(s, ",", allocator = context.temp_allocator) {
name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator)
switch name {
case "emoji":
result += {.Emoji}
case "sidenotes":
result += {.Sidenotes}
case "alerts":
result += {.Alerts}
case "highlight":
result += {.Highlight}
case "sections":
result += {.Sections}
}
}
return result
}
// Given a map[Extension]bool, apply it to ext.
apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) {
for name, val in config {
// TODO: Silently discards invalid values.
enabled := val.(json.Boolean) or_continue
switch name {
case "emoji":
if enabled {ext^ += {.Emoji}} else {ext^ -= {.Emoji}}
case "sidenotes":
if enabled {ext^ += {.Sidenotes}} else {ext^ -= {.Sidenotes}}
case "alerts":
if enabled {ext^ += {.Alerts}} else {ext^ -= {.Alerts}}
case "highlight":
if enabled {ext^ += {.Highlight}} else {ext^ -= {.Highlight}}
case "sections":
if enabled {ext^ += {.Sections}} else {ext^ -= {.Sections}}
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
package main package markdown
import "core:strings" import "core:strings"
+37 -35
View File
@@ -1,5 +1,7 @@
package main package main
import ts "treesitter"
import "core:log" import "core:log"
import "core:strings" import "core:strings"
@@ -11,7 +13,7 @@ Range :: struct {
} }
minify_html :: proc(source: string) -> string { minify_html :: proc(source: string) -> string {
gc := ensure_parser("html") gc := ts.ensure_parser("html")
if gc == nil { if gc == nil {
return source return source
} }
@@ -19,15 +21,15 @@ minify_html :: proc(source: string) -> string {
source_c := strings.clone_to_cstring(source) source_c := strings.clone_to_cstring(source)
defer delete(source_c) defer delete(source_c)
tree := ts_parser_parse_string(gc.parser, nil, source_c, u32(len(source))) tree := ts.parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
if tree == nil { if tree == nil {
return source return source
} }
defer ts_tree_delete(tree) defer ts.tree_delete(tree)
root := ts_tree_root_node(tree) root := ts.tree_root_node(tree)
if ts_node_has_error(root) { if ts.node_has_error(root) {
log.warnf("minify: HTML parse errors, skipping minification") log.warnf("minify: HTML parse errors, skipping minification")
return source return source
} }
@@ -95,32 +97,32 @@ minify_html :: proc(source: string) -> string {
} }
collect_html_ranges :: proc( collect_html_ranges :: proc(
node: TSNode, node: ts.Node,
source: string, source: string,
comments: ^[dynamic]Range, comments: ^[dynamic]Range,
preserves: ^[dynamic]Range, preserves: ^[dynamic]Range,
) { ) {
child_count := ts_node_named_child_count(node) child_count := ts.node_named_child_count(node)
for i in 0..<child_count { for i in 0..<child_count {
child := ts_node_named_child(node, u32(i)) child := ts.node_named_child(node, u32(i))
type_str := string(ts_node_type(child)) type_str := string(ts.node_type(child))
if type_str == "comment" { if type_str == "comment" {
append(comments, Range{ append(comments, Range{
start = ts_node_start_byte(child), start = ts.node_start_byte(child),
end = ts_node_end_byte(child), end = ts.node_end_byte(child),
}) })
} else if type_str == "script_element" || type_str == "style_element" { } else if type_str == "script_element" || type_str == "style_element" {
append(preserves, Range{ append(preserves, Range{
start = ts_node_start_byte(child), start = ts.node_start_byte(child),
end = ts_node_end_byte(child), end = ts.node_end_byte(child),
}) })
} else if type_str == "element" { } else if type_str == "element" {
tag := html_tag_name(child, source) tag := html_tag_name(child, source)
if is_preserve_tag(tag) { if is_preserve_tag(tag) {
append(preserves, Range{ append(preserves, Range{
start = ts_node_start_byte(child), start = ts.node_start_byte(child),
end = ts_node_end_byte(child), end = ts.node_end_byte(child),
}) })
} else { } else {
collect_html_ranges(child, source, comments, preserves) collect_html_ranges(child, source, comments, preserves)
@@ -131,17 +133,17 @@ collect_html_ranges :: proc(
} }
} }
html_tag_name :: proc(element: TSNode, source: string) -> string { html_tag_name :: proc(element: ts.Node, source: string) -> string {
child_count := ts_node_named_child_count(element) child_count := ts.node_named_child_count(element)
for i in 0..<child_count { for i in 0..<child_count {
child := ts_node_named_child(element, u32(i)) child := ts.node_named_child(element, u32(i))
if string(ts_node_type(child)) == "start_tag" { if string(ts.node_type(child)) == "start_tag" {
tag_child_count := ts_node_named_child_count(child) tag_child_count := ts.node_named_child_count(child)
for j in 0..<tag_child_count { for j in 0..<tag_child_count {
tag_child := ts_node_named_child(child, u32(j)) tag_child := ts.node_named_child(child, u32(j))
if string(ts_node_type(tag_child)) == "tag_name" { if string(ts.node_type(tag_child)) == "tag_name" {
start := ts_node_start_byte(tag_child) start := ts.node_start_byte(tag_child)
end := ts_node_end_byte(tag_child) end := ts.node_end_byte(tag_child)
return source[start:end] return source[start:end]
} }
} }
@@ -167,7 +169,7 @@ is_css_delim :: proc(c: u8) -> bool {
} }
minify_css :: proc(source: string) -> string { minify_css :: proc(source: string) -> string {
gc := ensure_parser("css") gc := ts.ensure_parser("css")
if gc == nil { if gc == nil {
return source return source
} }
@@ -175,15 +177,15 @@ minify_css :: proc(source: string) -> string {
source_c := strings.clone_to_cstring(source) source_c := strings.clone_to_cstring(source)
defer delete(source_c) defer delete(source_c)
tree := ts_parser_parse_string(gc.parser, nil, source_c, u32(len(source))) tree := ts.parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
if tree == nil { if tree == nil {
return source return source
} }
defer ts_tree_delete(tree) defer ts.tree_delete(tree)
root := ts_tree_root_node(tree) root := ts.tree_root_node(tree)
if ts_node_has_error(root) { if ts.node_has_error(root) {
log.warnf("minify: CSS parse errors, skipping minification") log.warnf("minify: CSS parse errors, skipping minification")
return source return source
} }
@@ -237,14 +239,14 @@ minify_css :: proc(source: string) -> string {
return strings.to_string(sb) return strings.to_string(sb)
} }
collect_css_comments :: proc(node: TSNode, comments: ^[dynamic]Range) { collect_css_comments :: proc(node: ts.Node, comments: ^[dynamic]Range) {
child_count := ts_node_named_child_count(node) child_count := ts.node_named_child_count(node)
for i in 0..<child_count { for i in 0..<child_count {
child := ts_node_named_child(node, u32(i)) child := ts.node_named_child(node, u32(i))
if string(ts_node_type(child)) == "comment" { if string(ts.node_type(child)) == "comment" {
append(comments, Range{ append(comments, Range{
start = ts_node_start_byte(child), start = ts.node_start_byte(child),
end = ts_node_end_byte(child), end = ts.node_end_byte(child),
}) })
} else { } else {
collect_css_comments(child, comments) collect_css_comments(child, comments)
+42 -63
View File
@@ -1,4 +1,3 @@
#+feature dynamic-literals
package main package main
import "mustache" import "mustache"
@@ -103,24 +102,24 @@ og_type :: proc(is_article: bool) -> string {
return "website" return "website"
} }
load_template :: proc(layouts_dir: string, name: string) -> mustache.Template { load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template {
data, _ := os.read_entire_file_from_path( data, ok := vfs_get(vfs, virtual_path)
fmt.tprintf("%s/%s", layouts_dir, name), if !ok {
context.allocator, log.warnf("thor: template %s not found", virtual_path)
) return mustache.Template{}
}
tpl, err := mustache.parse(string(data)) tpl, err := mustache.parse(string(data))
if err != nil { if err != nil {
log.warnf("thor: failed to parse template %s: %v", name, err) log.warnf("thor: failed to parse template %s: %v", virtual_path, err)
} }
return tpl return tpl
} }
get_template :: proc( get_template :: proc(
layouts_dir: string, vfs: ^VFS,
layout: string, layout: string,
cache: ^map[string]mustache.Template, cache: ^map[string]mustache.Template,
) -> mustache.Template { ) -> mustache.Template {
// Build fallback chain: <layout> → [section_index] → page → base
chain: [4]string chain: [4]string
n := 0 n := 0
chain[n] = layout; n += 1 chain[n] = layout; n += 1
@@ -139,18 +138,18 @@ get_template :: proc(
if cached, ok := cache[candidate]; ok { if cached, ok := cache[candidate]; ok {
return cached return cached
} }
filename := fmt.tprintf("%s.html", candidate) virtual := fmt.tprintf("layouts/%s.html", candidate)
if os.exists(fmt.tprintf("%s/%s", layouts_dir, filename)) { if _, ok := vfs_get(vfs, virtual); ok {
tpl := load_template(layouts_dir, filename) tpl := load_template(vfs, virtual)
cache[candidate] = tpl cache[candidate] = tpl
return tpl return tpl
} }
if candidate != chain[n - 1] { if candidate != chain[n - 1] {
log.warnf("thor: template %s not found, falling back", filename) log.warnf("thor: template %s not found, falling back", virtual)
} }
} }
log.errorf("thor: base.html not found in %s", layouts_dir) log.errorf("thor: base.html not found in VFS")
return mustache.Template{} return mustache.Template{}
} }
@@ -182,8 +181,8 @@ render_site :: proc(site: ^Site) {
sort_pages_by_date(pages) sort_pages_by_date(pages)
// Load shared resources // Load shared resources
partials := load_partials(site.layouts_dir) partials := load_partials(&site.vfs)
partials["base"] = load_template(site.layouts_dir, "base.html") partials["base"] = load_template(&site.vfs, "layouts/base.html")
template_cache: map[string]mustache.Template template_cache: map[string]mustache.Template
defer delete(template_cache) defer delete(template_cache)
@@ -226,7 +225,7 @@ render_site :: proc(site: ^Site) {
if page._is_index { if page._is_index {
continue continue
} }
tpl := get_template(site.layouts_dir, page.layout, &template_cache) tpl := get_template(&site.vfs, page.layout, &template_cache)
html := render_page_html(page, site, tpl, partials, base) html := render_page_html(page, site, tpl, partials, base)
if .Minify in site.features { if .Minify in site.features {
html = minify_html(html) html = minify_html(html)
@@ -247,7 +246,7 @@ render_site :: proc(site: ^Site) {
} }
layout := fmt.tprintf("%s_index", section) layout := fmt.tprintf("%s_index", section)
section_tpl := get_template(site.layouts_dir, layout, &template_cache) section_tpl := get_template(&site.vfs, layout, &template_cache)
html := render_section( html := render_section(
site, site,
section, section,
@@ -265,7 +264,7 @@ render_site :: proc(site: ^Site) {
// Render home page // Render home page
if has_home { if has_home {
home_tpl := get_template(site.layouts_dir, "home", &template_cache) home_tpl := get_template(&site.vfs, "home", &template_cache)
home_html := render_home_html(home, site, home_tpl, partials, base) home_html := render_home_html(home, site, home_tpl, partials, base)
if .Minify in site.features { if .Minify in site.features {
home_html = minify_html(home_html) home_html = minify_html(home_html)
@@ -282,7 +281,7 @@ render_site :: proc(site: ^Site) {
write_file(fmt.tprintf("%s/sitemap.xml", site.output_dir), sitemap) write_file(fmt.tprintf("%s/sitemap.xml", site.output_dir), sitemap)
// Copy and optionally minify assets directory // Copy and optionally minify assets directory
copy_assets_dir(site.assets_dir, site.output_dir, site.features) copy_assets_dir(&site.vfs, site.output_dir, site.features)
// Generate robots.txt // Generate robots.txt
robots := fmt.aprintf("User-agent: *\nAllow: /\nSitemap: %s/sitemap.xml\n", site.base_url) robots := fmt.aprintf("User-agent: *\nAllow: /\nSitemap: %s/sitemap.xml\n", site.base_url)
@@ -394,52 +393,32 @@ render_section :: proc(
return render_template(content_tpl, data, partials) return render_template(content_tpl, data, partials)
} }
load_partials :: proc(layouts_dir: string) -> map[string]mustache.Template { load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template {
partials: map[string]mustache.Template partials: map[string]mustache.Template
partials_dir := fmt.tprintf("%s/partials", layouts_dir) prefix := "layouts/partials/"
load_partials_recursive(&partials, partials_dir, "") for virtual_path in vfs.files {
return partials if !strings.has_prefix(virtual_path, prefix) {
} continue
load_partials_recursive :: proc(
partials: ^map[string]mustache.Template,
base_dir: string,
rel_prefix: string,
) {
entries, err := os.read_all_directory_by_path(base_dir, context.allocator)
if err != nil {
return
}
defer os.file_info_slice_delete(entries, context.allocator)
for entry in entries {
#partial switch entry.type {
case .Regular:
name := entry.name
if !strings.has_suffix(name, ".html") {
continue
}
stripped := name[:len(name) - len(".html")]
key := stripped
if rel_prefix != "" {
key = fmt.tprintf("%s/%s", rel_prefix, stripped)
}
data, ok := os.read_entire_file_from_path(entry.fullpath, context.allocator)
if ok == nil {
tpl, perr := mustache.parse(string(data))
if perr != nil {
log.warnf("thor: failed to parse partial %s: %v", key, perr)
}
partials[key] = tpl
}
case .Directory:
sub_prefix := entry.name
if rel_prefix != "" {
sub_prefix = fmt.tprintf("%s/%s", rel_prefix, entry.name)
}
load_partials_recursive(partials, entry.fullpath, sub_prefix)
} }
if !strings.has_suffix(virtual_path, ".html") {
continue
}
stripped := virtual_path[len(prefix):]
key := stripped[:len(stripped) - len(".html")]
data, ok := vfs_get(vfs, virtual_path)
if !ok {
continue
}
tpl, err := mustache.parse(string(data))
if err != nil {
log.warnf("thor: failed to parse partial %s: %v", key, err)
continue
}
partials[key] = tpl
} }
return partials
} }
format_date :: proc(iso: string) -> string { format_date :: proc(iso: string) -> string {
+20 -61
View File
@@ -8,11 +8,15 @@ import "core:mem"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import md "markdown"
// Site is the primary workhorse. // Site is the primary workhorse.
Site :: struct { Site :: struct {
arena: mem.Dynamic_Arena, arena: mem.Dynamic_Arena,
pages: [dynamic]Page, pages: [dynamic]Page,
modules: [dynamic]string,
vfs: VFS,
title: string, title: string,
description: string, description: string,
author: string, author: string,
@@ -24,7 +28,7 @@ Site :: struct {
layouts_dir: string, layouts_dir: string,
params: json.Object, params: json.Object,
features: bit_set[Feature], features: bit_set[Feature],
markdown_extensions: bit_set[Markdown_Extension], markdown_extensions: bit_set[md.Extension],
} }
Feature :: enum { Feature :: enum {
@@ -33,22 +37,6 @@ Feature :: enum {
Watch, Watch,
} }
Markdown_Extension :: enum {
Emoji,
Sidenotes,
Alerts,
Highlight,
Sections,
}
DEFAULT_MARKDOWN_EXTENSIONS :: bit_set[Markdown_Extension] {
.Emoji,
.Sidenotes,
.Alerts,
// .Highlight,
// .Sections,
}
// Configuration loaded from `thor.json`. Gets folded in to Site before // Configuration loaded from `thor.json`. Gets folded in to Site before
// Flags // Flags
Config_File :: struct { Config_File :: struct {
@@ -62,6 +50,7 @@ Config_File :: struct {
layouts_dir: string, layouts_dir: string,
markdown_extensions: json.Value, markdown_extensions: json.Value,
params: json.Value, params: json.Value,
modules: json.Value,
} }
// Configuration loaded from command line arguments. Gets folded in to Site // Configuration loaded from command line arguments. Gets folded in to Site
@@ -72,6 +61,7 @@ Flags :: struct {
content_dir: string `args:"name=content" usage:"Path to content directory"`, content_dir: string `args:"name=content" usage:"Path to content directory"`,
assets_dir: string `args:"name=assets" usage:"Path to assets directory (CSS, JS, fonts, images)"`, assets_dir: string `args:"name=assets" usage:"Path to assets directory (CSS, JS, fonts, images)"`,
output_dir: string `args:"name=output" usage:"Path to output directory (default: public/)"`, output_dir: string `args:"name=output" usage:"Path to output directory (default: public/)"`,
layouts_dir: string `args:"name=layouts" usage:"Path to layouts directory"`,
drafts: bool `args:"name=drafts" usage:"Include draft pages in the build"`, drafts: bool `args:"name=drafts" usage:"Include draft pages in the build"`,
watch: bool `usage:"Rebuild on file changes (polls every 5 seconds)"`, watch: bool `usage:"Rebuild on file changes (polls every 5 seconds)"`,
minify: bool `args:"name=minify" usage:"Minify HTML output and CSS assets"`, minify: bool `args:"name=minify" usage:"Minify HTML output and CSS assets"`,
@@ -85,7 +75,7 @@ init_site :: proc(site: ^Site, args: []string) {
// Set defaults // Set defaults
site.base_url = "http://localhost:8080" site.base_url = "http://localhost:8080"
site.markdown_extensions = DEFAULT_MARKDOWN_EXTENSIONS site.markdown_extensions = md.DEFAULT_EXTENSIONS
_flags: Flags _flags: Flags
flags.parse_or_exit(&_flags, args, .Odin, alloc) flags.parse_or_exit(&_flags, args, .Odin, alloc)
@@ -159,7 +149,15 @@ site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string)
// Apply markdown extensions from config // Apply markdown extensions from config
if ext_obj, ok := config.markdown_extensions.(json.Object); ok { if ext_obj, ok := config.markdown_extensions.(json.Object); ok {
apply_extension_config(&site.markdown_extensions, ext_obj) md.apply_extension_config(&site.markdown_extensions, ext_obj)
}
if modules_arr, ok := config.modules.(json.Array); ok {
for &item in modules_arr {
if s, ok2 := item.(json.String); ok2 {
append(&site.modules, fmt.tprintf("%s/%s", config_dir, s))
}
}
} }
} }
@@ -175,53 +173,14 @@ site_apply_cli_flags :: proc(site: ^Site, flags: Flags) {
if flags.content_dir != "" do site.content_dir = flags.content_dir if flags.content_dir != "" do site.content_dir = flags.content_dir
if flags.assets_dir != "" do site.assets_dir = flags.assets_dir if flags.assets_dir != "" do site.assets_dir = flags.assets_dir
if flags.output_dir != "" do site.output_dir = flags.output_dir if flags.output_dir != "" do site.output_dir = flags.output_dir
if flags.layouts_dir != "" do site.layouts_dir = flags.layouts_dir
if flags.drafts {site.features += {.Drafts}} if flags.drafts {site.features += {.Drafts}}
if flags.watch {site.features += {.Watch}} if flags.watch {site.features += {.Watch}}
if flags.minify {site.features += {.Minify}} if flags.minify {site.features += {.Minify}}
site.markdown_extensions += parse_extension_list(flags.md_enable) site.markdown_extensions += md.parse_extension_list(flags.md_enable)
site.markdown_extensions -= parse_extension_list(flags.md_disable) site.markdown_extensions -= md.parse_extension_list(flags.md_disable)
}
parse_extension_list :: proc(s: string) -> bit_set[Markdown_Extension] {
result: bit_set[Markdown_Extension]
if s == "" do return result
for part in strings.split(s, ",", allocator = context.temp_allocator) {
name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator)
switch name {
case "emoji":
result += {.Emoji}
case "sidenotes":
result += {.Sidenotes}
case "alerts":
result += {.Alerts}
case "highlight":
result += {.Highlight}
case "sections":
result += {.Sections}
}
}
return result
}
apply_extension_config :: proc(ext: ^bit_set[Markdown_Extension], config: json.Object) {
for name, val in config {
enabled, is_bool := val.(json.Boolean)
if !is_bool do continue
switch name {
case "emoji":
if enabled {ext^ += {.Emoji}} else {ext^ -= {.Emoji}}
case "sidenotes":
if enabled {ext^ += {.Sidenotes}} else {ext^ -= {.Sidenotes}}
case "alerts":
if enabled {ext^ += {.Alerts}} else {ext^ -= {.Alerts}}
case "highlight":
if enabled {ext^ += {.Highlight}} else {ext^ -= {.Highlight}}
case "sections":
if enabled {ext^ += {.Sections}} else {ext^ -= {.Sections}}
}
}
} }
site_allocator :: proc(site: ^Site) -> mem.Allocator { site_allocator :: proc(site: ^Site) -> mem.Allocator {
-129
View File
@@ -1,129 +0,0 @@
package main
import "core:c"
GRAPHS_PATH: string = "/home/spencer/.config/helix/runtime/grammars"
QUERIES_PATH: string = "/nix/store/n9da8d007ygbgsx983jr3ar3wb1fsh6q-helix-25.07.1/lib/runtime/queries"
TSLanguage :: distinct rawptr
TSParser :: distinct rawptr
TSTree :: distinct rawptr
TSQuery :: distinct rawptr
TSQueryCursor :: distinct rawptr
TSPoint :: struct {
row: u32,
column: u32,
}
TSNode :: struct {
ctx: [4]u32,
id: rawptr,
tree: rawptr,
}
TSQueryCapture :: struct {
node: TSNode,
index: u32,
_: u32,
}
TSQueryMatch :: struct {
id: u32,
pattern_index: u16,
capture_count: u16,
captures: [^]TSQueryCapture,
}
TSQueryError :: enum c.int {
None = 0,
Syntax,
NodeType,
Field,
Capture,
Structure,
Language,
}
RTLD_LAZY :: c.int(1)
foreign import lib "system:tree-sitter"
foreign import libdl "system:dl"
foreign import html_grammar "system:tree-sitter-html"
foreign import css_grammar "system:tree-sitter-css"
foreign lib {
ts_parser_new :: proc() -> TSParser ---
ts_parser_delete :: proc(self: TSParser) ---
ts_parser_set_language :: proc(self: TSParser, language: TSLanguage) -> bool ---
ts_parser_parse_string :: proc(
self: TSParser,
old_tree: TSTree,
string: cstring,
length: u32,
) -> TSTree ---
}
foreign lib {
ts_tree_root_node :: proc(self: TSTree) -> TSNode ---
ts_tree_delete :: proc(self: TSTree) ---
}
foreign lib {
ts_node_start_byte :: proc(self: TSNode) -> u32 ---
ts_node_end_byte :: proc(self: TSNode) -> u32 ---
ts_node_has_error :: proc(self: TSNode) -> bool ---
ts_node_is_error :: proc(self: TSNode) -> bool ---
ts_node_child_count :: proc(self: TSNode) -> u32 ---
ts_node_child :: proc(self: TSNode, child_index: u32) -> TSNode ---
ts_node_named_child_count :: proc(self: TSNode) -> u32 ---
ts_node_named_child :: proc(self: TSNode, child_index: u32) -> TSNode ---
ts_node_start_point :: proc(self: TSNode) -> TSPoint ---
ts_node_type :: proc(self: TSNode) -> cstring ---
ts_node_parent :: proc(self: TSNode) -> TSNode ---
}
foreign lib {
ts_query_new :: proc(
language: TSLanguage,
source: cstring,
source_len: u32,
error_offset: ^u32,
error_type: ^TSQueryError,
) -> TSQuery ---
ts_query_delete :: proc(self: TSQuery) ---
ts_query_capture_name_for_id :: proc(
self: TSQuery,
index: u32,
length: ^u32,
) -> cstring ---
}
foreign lib {
ts_query_cursor_new :: proc() -> TSQueryCursor ---
ts_query_cursor_delete :: proc(self: TSQueryCursor) ---
ts_query_cursor_exec :: proc(
self: TSQueryCursor,
query: TSQuery,
node: TSNode,
) ---
ts_query_cursor_next_capture :: proc(
self: TSQueryCursor,
match: ^TSQueryMatch,
capture_index: ^u32,
) -> bool ---
}
foreign libdl {
dlopen :: proc(filename: cstring, flags: c.int) -> rawptr ---
dlsym :: proc(handle: rawptr, symbol: cstring) -> rawptr ---
dlclose :: proc(handle: rawptr) -> c.int ---
}
foreign html_grammar {
tree_sitter_html :: proc() -> TSLanguage ---
}
foreign css_grammar {
tree_sitter_css :: proc() -> TSLanguage ---
}
+333
View File
@@ -0,0 +1,333 @@
package treesitter
import "core:c"
import "core:fmt"
import "core:log"
import "core:os"
import "core:strings"
GRAPHS_PATH: string = "/home/spencer/.config/helix/runtime/grammars"
QUERIES_PATH: string = "/nix/store/n9da8d007ygbgsx983jr3ar3wb1fsh6q-helix-25.07.1/lib/runtime/queries"
Language :: distinct rawptr
Parser :: distinct rawptr
Tree :: distinct rawptr
Query :: distinct rawptr
Query_Cursor :: distinct rawptr
Point :: struct {
row: u32,
column: u32,
}
Node :: struct {
ctx: [4]u32,
id: rawptr,
tree: rawptr,
}
Query_Capture :: struct {
node: Node,
index: u32,
_: u32,
}
Query_Match :: struct {
id: u32,
pattern_index: u16,
capture_count: u16,
captures: [^]Query_Capture,
}
Query_Error :: enum c.int {
None = 0,
Syntax,
NodeType,
Field,
Capture,
Structure,
Language,
}
RTLD_LAZY :: c.int(1)
foreign import lib "system:tree-sitter"
foreign import libdl "system:dl"
foreign import html_grammar "system:tree-sitter-html"
foreign import css_grammar "system:tree-sitter-css"
@(link_prefix="ts_")
foreign lib {
parser_new :: proc() -> Parser ---
parser_delete :: proc(self: Parser) ---
parser_set_language :: proc(self: Parser, language: Language) -> bool ---
parser_parse_string :: proc(
self: Parser,
old_tree: Tree,
string: cstring,
length: u32,
) -> Tree ---
}
@(link_prefix="ts_")
foreign lib {
tree_root_node :: proc(self: Tree) -> Node ---
tree_delete :: proc(self: Tree) ---
}
@(link_prefix="ts_")
foreign lib {
node_start_byte :: proc(self: Node) -> u32 ---
node_end_byte :: proc(self: Node) -> u32 ---
node_has_error :: proc(self: Node) -> bool ---
node_is_error :: proc(self: Node) -> bool ---
node_child_count :: proc(self: Node) -> u32 ---
node_child :: proc(self: Node, child_index: u32) -> Node ---
node_named_child_count :: proc(self: Node) -> u32 ---
node_named_child :: proc(self: Node, child_index: u32) -> Node ---
node_start_point :: proc(self: Node) -> Point ---
node_type :: proc(self: Node) -> cstring ---
node_parent :: proc(self: Node) -> Node ---
}
@(link_prefix="ts_")
foreign lib {
query_new :: proc(
language: Language,
source: cstring,
source_len: u32,
error_offset: ^u32,
error_type: ^Query_Error,
) -> Query ---
query_delete :: proc(self: Query) ---
query_capture_name_for_id :: proc(
self: Query,
index: u32,
length: ^u32,
) -> cstring ---
}
@(link_prefix="ts_")
foreign lib {
query_cursor_new :: proc() -> Query_Cursor ---
query_cursor_delete :: proc(self: Query_Cursor) ---
query_cursor_exec :: proc(
self: Query_Cursor,
query: Query,
node: Node,
) ---
query_cursor_next_capture :: proc(
self: Query_Cursor,
match: ^Query_Match,
capture_index: ^u32,
) -> bool ---
}
foreign libdl {
dlopen :: proc(filename: cstring, flags: c.int) -> rawptr ---
dlsym :: proc(handle: rawptr, symbol: cstring) -> rawptr ---
dlclose :: proc(handle: rawptr) -> c.int ---
}
foreign html_grammar {
tree_sitter_html :: proc() -> Language ---
}
foreign css_grammar {
tree_sitter_css :: proc() -> Language ---
}
Grammar_Cache :: struct {
language: Language,
parser: Parser,
query: Query,
query_failed: bool,
}
Get_Language_Proc :: #type proc() -> Language
grammar_cache: map[string]^Grammar_Cache
builtin_language :: proc(lang: string) -> (language: Language, ok: bool) {
switch lang {
case "html":
language = tree_sitter_html()
ok = true
case "css":
language = tree_sitter_css()
ok = true
}
return
}
ensure_parser :: proc(lang: string) -> ^Grammar_Cache {
if grammar_cache == nil {
grammar_cache = make(map[string]^Grammar_Cache)
}
if cached, ok := grammar_cache[lang]; ok {
return cached
}
grammar_cache[lang] = nil
language: Language
if builtin, ok := builtin_language(lang); ok {
language = builtin
} else {
if GRAPHS_PATH == "" {
log.warnf("treesitter: no grammars path set, skipping %s", lang)
return nil
}
so_path := fmt.tprintf("%s/%s.so", GRAPHS_PATH, lang)
so_c := strings.clone_to_cstring(so_path)
defer delete(so_c)
handle := dlopen(so_c, RTLD_LAZY)
if handle == nil {
log.warnf("treesitter: cannot load grammar %s (%s)", lang, so_path)
return nil
}
sym_name := fmt.tprintf("tree_sitter_%s", lang)
sym_c := strings.clone_to_cstring(sym_name)
defer delete(sym_c)
sym := dlsym(handle, sym_c)
if sym == nil {
log.errorf("treesitter: cannot find symbol %s in %s", sym_name, so_path)
return nil
}
get_language := transmute(Get_Language_Proc)(sym)
language = get_language()
}
parser := parser_new()
if parser == nil {
log.errorf("treesitter: cannot create parser for %s", lang)
return nil
}
if !parser_set_language(parser, language) {
log.errorf("treesitter: ABI mismatch for %s grammar", lang)
parser_delete(parser)
return nil
}
gc := new(Grammar_Cache)
gc.language = language
gc.parser = parser
grammar_cache[lang] = gc
return gc
}
load_grammar :: proc(lang: string) -> ^Grammar_Cache {
gc := ensure_parser(lang)
if gc == nil {
return nil
}
if gc.query != nil {
return gc
}
if gc.query_failed {
return nil
}
if QUERIES_PATH == "" {
log.warnf("treesitter: no queries path set, skipping %s", lang)
gc.query_failed = true
return nil
}
query_path := fmt.tprintf("%s/%s/highlights.scm", QUERIES_PATH, lang)
query_src, err := os.read_entire_file_from_path(query_path, context.allocator)
if err != nil {
log.warnf("treesitter: cannot load query %s", query_path)
gc.query_failed = true
return nil
}
query_str := string(query_src)
query_c := strings.clone_to_cstring(query_str)
defer delete(query_c)
err_offset: u32
err_type: Query_Error
query := query_new(
gc.language,
query_c,
u32(len(query_src)),
&err_offset,
&err_type,
)
if query == nil {
tok := extract_query_token(query_src, err_offset)
cause := fmt.tprintf("query error at byte %d (type %v)", err_offset, err_type)
#partial switch err_type {
case .NodeType:
if tok != "" {
cause = fmt.tprintf("query references unknown node type '%s' (byte %d); the grammar (.so) and query (.scm) are likely from different tree-sitter-%s versions", tok, err_offset, lang)
} else {
cause = fmt.tprintf("query references an unknown node type at byte %d; the grammar (.so) and query (.scm) are likely from different tree-sitter-%s versions", err_offset, lang)
}
case .Field:
cause = fmt.tprintf("query references unknown field '%s' at byte %d", tok, err_offset)
case .Capture:
cause = fmt.tprintf("query uses an invalid capture '%s' at byte %d", tok, err_offset)
case .Syntax:
cause = fmt.tprintf("query has a syntax error at byte %d", err_offset)
case .Structure:
cause = fmt.tprintf("query has an illegal pattern structure at byte %d", err_offset)
case .Language:
cause = "grammar language is null (broken grammar .so)"
}
log.errorf("treesitter: %s query failed: %s", lang, cause)
_, is_builtin := builtin_language(lang)
if !is_builtin {
so_path := fmt.tprintf("%s/%s.so", GRAPHS_PATH, lang)
gram_v := helix_version_from_path(so_path)
query_v := helix_version_from_path(query_path)
gram_note := "(version unknown)"
if gram_v != "" do gram_note = fmt.tprintf("helix %s", gram_v)
query_note := "(version unknown)"
if query_v != "" do query_note = fmt.tprintf("helix %s", query_v)
log.errorf(" grammar: %s [%s]", so_path, gram_note)
log.errorf(" query: %s [%s]", query_path, query_note)
if gram_v != "" && query_v != "" && gram_v != query_v {
log.errorf(" >> helix VERSION MISMATCH: grammar %s vs query %s", gram_v, query_v)
}
}
gc.query_failed = true
return nil
}
gc.query = query
return gc
}
extract_query_token :: proc(src: []byte, offset: u32) -> string {
end := offset
for int(end) < len(src) {
c := src[end]
is_ident := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.'
if !is_ident do break
end += 1
}
if end <= offset do return ""
return string(src[offset:end])
}
helix_version_from_path :: proc(path: string) -> string {
tag := "-helix-"
idx := strings.index(path, tag)
if idx < 0 do return ""
start := idx + len(tag)
end := start
for end < len(path) {
c := path[end]
if !((c >= '0' && c <= '9') || c == '.') do break
end += 1
}
if end <= start do return ""
return path[start:end]
}
+80
View File
@@ -0,0 +1,80 @@
package main
import "core:fmt"
import "core:os"
// Virtual File System Entry
VFS_Entry :: struct {
fs_path: string,
data: []byte,
}
// Virtual File System
VFS :: struct {
files: map[string]VFS_Entry,
}
build_vfs :: proc(site: ^Site) {
site.vfs.files = make(map[string]VFS_Entry, site_allocator(site))
mount_dir(&site.vfs, fmt.tprintf("%s/layouts", DEFAULTS_PATH), "layouts")
for i := len(site.modules) - 1; i >= 0; i -= 1 {
module := site.modules[i]
mount_subdir(&site.vfs, module, "layouts")
mount_subdir(&site.vfs, module, "assets")
}
mount_dir(&site.vfs, site.layouts_dir, "layouts")
mount_dir(&site.vfs, site.assets_dir, "assets")
}
mount_subdir :: proc(vfs: ^VFS, module_dir: string, target: string) {
source := fmt.tprintf("%s/%s", module_dir, target)
mount_dir(vfs, source, target)
}
mount_dir :: proc(vfs: ^VFS, source_dir: string, target: string) {
if !os.exists(source_dir) {
return
}
mount_recursive(vfs, source_dir, target)
}
mount_recursive :: proc(vfs: ^VFS, current_dir: string, target_prefix: string) {
entries, err := os.read_all_directory_by_path(current_dir, context.allocator)
if err != nil {
return
}
defer os.file_info_slice_delete(entries, context.allocator)
for entry in entries {
#partial switch entry.type {
case .Regular:
virtual := fmt.tprintf("%s/%s", target_prefix, entry.name)
vfs.files[virtual] = VFS_Entry {
fs_path = entry.fullpath,
}
case .Directory:
sub_prefix := fmt.tprintf("%s/%s", target_prefix, entry.name)
mount_recursive(vfs, entry.fullpath, sub_prefix)
case:
}
}
}
vfs_get :: proc(vfs: ^VFS, virtual_path: string) -> ([]byte, bool) {
entry, ok := vfs.files[virtual_path]
if !ok {
return nil, false
}
if entry.data != nil {
return entry.data, true
}
data, err := os.read_entire_file_from_path(entry.fs_path, context.allocator)
if err != nil {
return nil, false
}
return data, true
}