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
- [ ] "Zero is beautiful"
- [ ] Content-hash fingerprinting for CSS and JS cache busting
- [ ] Clean up the default layouts
- [ ] Performance
- [ ] 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
`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.
- [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
@@ -34,7 +37,7 @@
- [ ] event based
- [ ] 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.
- [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely
- [ ] Mount content in VFS
- [ ] commands
- [ ] `build` alias of default
- [ ] `init` set up new project
@@ -43,16 +46,21 @@
- [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
- [ ] Review every file in thor
- [ ] Review assets.odin
- [ ] Review alerts.odin
- [ ] Review content.odin
- [ ] Review emoji.odin
- [ ] Review defaults.odin
- [ ] Review feed.odin
- [ ] Review footnotes.odin
- [ ] Review frontmatter.odin
- [ ] Review highlight.odin
- [ ] Review main.odin
- [ ] Review minifiy.odin
- [ ] Review render.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
- [x] Review site.odin
- [ ] Review tree_sitter.odin
- [ ] Review treesitter/treesitter.odin
- [ ] Review vfs.odin
+17 -39
View File
@@ -5,57 +5,35 @@ import "core:log"
import "core:os"
import "core:strings"
// copy_assets_dir recursively copies files from assets_dir to output_dir.
// .css files are minified when .Minify is enabled; all other files are copied verbatim.
// Silently skips if assets_dir doesn't exist.
copy_assets_dir :: proc(assets_dir: string, output_dir: string, features: bit_set[Feature]) {
if !os.exists(assets_dir) {
return
copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature]) {
for virtual_path, entry in vfs.files {
if !strings.has_prefix(virtual_path, "assets/") {
continue
}
copy_assets_recursive(assets_dir, "", output_dir, features)
}
copy_assets_recursive :: proc(
current: string,
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 {
rel := rel_prefix == "" ? entry.name : fmt.tprintf("%s/%s", rel_prefix, entry.name)
switch entry.type {
case .Regular:
rel := virtual_path[len("assets/"):]
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 {
log.warnf("thor: cannot read %s: %v", entry.fullpath, read_err)
continue
if .Minify in features && strings.has_suffix(rel, ".css") {
data, ok := vfs_get(vfs, virtual_path)
if ok {
write_file(dest, minify_css(string(data)))
}
} else if entry.data != nil {
if err := os.write_entire_file(dest, entry.data); err != nil {
log.warnf("thor: cannot write %s: %v", dest, err)
}
minified := minify_css(string(data))
write_file(dest, minified)
} else {
if err := os.copy_file(dest, entry.fullpath); err != nil {
log.warnf("thor: cannot copy %s: %v", entry.fullpath, err)
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
import cm "vendor:commonmark"
import md "markdown"
import "core:fmt"
import "core:log"
@@ -101,7 +101,7 @@ load_page :: proc(
section: string,
slug: string,
is_index: bool,
ext: bit_set[Markdown_Extension],
ext: bit_set[md.Extension],
) -> (
page: Page,
ok: bool,
@@ -132,29 +132,7 @@ load_page :: proc(
if strings.has_suffix(file_path, ".html") {
page.body_html = strings.clone(body)
} else {
sn_defs := make(map[string]string)
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
page.body_html = md.process(body, ext, file_path)
}
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)
// TODO: Make it so this isn't necessary
context.allocator = site_allocator(&site)
build_vfs(&site)
site_load_content(&site)
render_site(&site)
if !(.Watch in site.features) {
break
}
(.Watch in site.features) or_break
time.sleep(5 * time.Second)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
#+feature dynamic-literals
package main
package markdown
import "core:fmt"
import "core:strings"
+1 -1
View File
@@ -1,5 +1,5 @@
#+feature dynamic-literals
package main
package markdown
import "core:strings"
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package markdown
import cm "vendor:commonmark"
@@ -1,6 +1,6 @@
#+feature dynamic-literals
#+test
package main
package markdown
import "core:strings"
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"
+37 -35
View File
@@ -1,5 +1,7 @@
package main
import ts "treesitter"
import "core:log"
import "core:strings"
@@ -11,7 +13,7 @@ Range :: struct {
}
minify_html :: proc(source: string) -> string {
gc := ensure_parser("html")
gc := ts.ensure_parser("html")
if gc == nil {
return source
}
@@ -19,15 +21,15 @@ minify_html :: proc(source: string) -> string {
source_c := strings.clone_to_cstring(source)
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 {
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")
return source
}
@@ -95,32 +97,32 @@ minify_html :: proc(source: string) -> string {
}
collect_html_ranges :: proc(
node: TSNode,
node: ts.Node,
source: string,
comments: ^[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 {
child := ts_node_named_child(node, u32(i))
type_str := string(ts_node_type(child))
child := ts.node_named_child(node, u32(i))
type_str := string(ts.node_type(child))
if type_str == "comment" {
append(comments, Range{
start = ts_node_start_byte(child),
end = ts_node_end_byte(child),
start = ts.node_start_byte(child),
end = ts.node_end_byte(child),
})
} else if type_str == "script_element" || type_str == "style_element" {
append(preserves, Range{
start = ts_node_start_byte(child),
end = ts_node_end_byte(child),
start = ts.node_start_byte(child),
end = ts.node_end_byte(child),
})
} else if type_str == "element" {
tag := html_tag_name(child, source)
if is_preserve_tag(tag) {
append(preserves, Range{
start = ts_node_start_byte(child),
end = ts_node_end_byte(child),
start = ts.node_start_byte(child),
end = ts.node_end_byte(child),
})
} else {
collect_html_ranges(child, source, comments, preserves)
@@ -131,17 +133,17 @@ collect_html_ranges :: proc(
}
}
html_tag_name :: proc(element: TSNode, source: string) -> string {
child_count := ts_node_named_child_count(element)
html_tag_name :: proc(element: ts.Node, source: string) -> string {
child_count := ts.node_named_child_count(element)
for i in 0..<child_count {
child := ts_node_named_child(element, u32(i))
if string(ts_node_type(child)) == "start_tag" {
tag_child_count := ts_node_named_child_count(child)
child := ts.node_named_child(element, u32(i))
if string(ts.node_type(child)) == "start_tag" {
tag_child_count := ts.node_named_child_count(child)
for j in 0..<tag_child_count {
tag_child := ts_node_named_child(child, u32(j))
if string(ts_node_type(tag_child)) == "tag_name" {
start := ts_node_start_byte(tag_child)
end := ts_node_end_byte(tag_child)
tag_child := ts.node_named_child(child, u32(j))
if string(ts.node_type(tag_child)) == "tag_name" {
start := ts.node_start_byte(tag_child)
end := ts.node_end_byte(tag_child)
return source[start:end]
}
}
@@ -167,7 +169,7 @@ is_css_delim :: proc(c: u8) -> bool {
}
minify_css :: proc(source: string) -> string {
gc := ensure_parser("css")
gc := ts.ensure_parser("css")
if gc == nil {
return source
}
@@ -175,15 +177,15 @@ minify_css :: proc(source: string) -> string {
source_c := strings.clone_to_cstring(source)
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 {
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")
return source
}
@@ -237,14 +239,14 @@ minify_css :: proc(source: string) -> string {
return strings.to_string(sb)
}
collect_css_comments :: proc(node: TSNode, comments: ^[dynamic]Range) {
child_count := ts_node_named_child_count(node)
collect_css_comments :: proc(node: ts.Node, comments: ^[dynamic]Range) {
child_count := ts.node_named_child_count(node)
for i in 0..<child_count {
child := ts_node_named_child(node, u32(i))
if string(ts_node_type(child)) == "comment" {
child := ts.node_named_child(node, u32(i))
if string(ts.node_type(child)) == "comment" {
append(comments, Range{
start = ts_node_start_byte(child),
end = ts_node_end_byte(child),
start = ts.node_start_byte(child),
end = ts.node_end_byte(child),
})
} else {
collect_css_comments(child, comments)
+38 -59
View File
@@ -1,4 +1,3 @@
#+feature dynamic-literals
package main
import "mustache"
@@ -103,24 +102,24 @@ og_type :: proc(is_article: bool) -> string {
return "website"
}
load_template :: proc(layouts_dir: string, name: string) -> mustache.Template {
data, _ := os.read_entire_file_from_path(
fmt.tprintf("%s/%s", layouts_dir, name),
context.allocator,
)
load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template {
data, ok := vfs_get(vfs, virtual_path)
if !ok {
log.warnf("thor: template %s not found", virtual_path)
return mustache.Template{}
}
tpl, err := mustache.parse(string(data))
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
}
get_template :: proc(
layouts_dir: string,
vfs: ^VFS,
layout: string,
cache: ^map[string]mustache.Template,
) -> mustache.Template {
// Build fallback chain: <layout> → [section_index] → page → base
chain: [4]string
n := 0
chain[n] = layout; n += 1
@@ -139,18 +138,18 @@ get_template :: proc(
if cached, ok := cache[candidate]; ok {
return cached
}
filename := fmt.tprintf("%s.html", candidate)
if os.exists(fmt.tprintf("%s/%s", layouts_dir, filename)) {
tpl := load_template(layouts_dir, filename)
virtual := fmt.tprintf("layouts/%s.html", candidate)
if _, ok := vfs_get(vfs, virtual); ok {
tpl := load_template(vfs, virtual)
cache[candidate] = tpl
return tpl
}
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{}
}
@@ -182,8 +181,8 @@ render_site :: proc(site: ^Site) {
sort_pages_by_date(pages)
// Load shared resources
partials := load_partials(site.layouts_dir)
partials["base"] = load_template(site.layouts_dir, "base.html")
partials := load_partials(&site.vfs)
partials["base"] = load_template(&site.vfs, "layouts/base.html")
template_cache: map[string]mustache.Template
defer delete(template_cache)
@@ -226,7 +225,7 @@ render_site :: proc(site: ^Site) {
if page._is_index {
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)
if .Minify in site.features {
html = minify_html(html)
@@ -247,7 +246,7 @@ render_site :: proc(site: ^Site) {
}
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(
site,
section,
@@ -265,7 +264,7 @@ render_site :: proc(site: ^Site) {
// Render home page
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)
if .Minify in site.features {
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)
// 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
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)
}
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_dir := fmt.tprintf("%s/partials", layouts_dir)
load_partials_recursive(&partials, partials_dir, "")
return partials
}
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") {
prefix := "layouts/partials/"
for virtual_path in vfs.files {
if !strings.has_prefix(virtual_path, prefix) {
continue
}
stripped := name[:len(name) - len(".html")]
key := stripped
if rel_prefix != "" {
key = fmt.tprintf("%s/%s", rel_prefix, stripped)
if !strings.has_suffix(virtual_path, ".html") {
continue
}
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)
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
}
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)
}
}
return partials
}
format_date :: proc(iso: string) -> string {
+20 -61
View File
@@ -8,11 +8,15 @@ import "core:mem"
import "core:os"
import "core:strings"
import md "markdown"
// Site is the primary workhorse.
Site :: struct {
arena: mem.Dynamic_Arena,
pages: [dynamic]Page,
modules: [dynamic]string,
vfs: VFS,
title: string,
description: string,
author: string,
@@ -24,7 +28,7 @@ Site :: struct {
layouts_dir: string,
params: json.Object,
features: bit_set[Feature],
markdown_extensions: bit_set[Markdown_Extension],
markdown_extensions: bit_set[md.Extension],
}
Feature :: enum {
@@ -33,22 +37,6 @@ Feature :: enum {
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
// Flags
Config_File :: struct {
@@ -62,6 +50,7 @@ Config_File :: struct {
layouts_dir: string,
markdown_extensions: json.Value,
params: json.Value,
modules: json.Value,
}
// 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"`,
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/)"`,
layouts_dir: string `args:"name=layouts" usage:"Path to layouts directory"`,
drafts: bool `args:"name=drafts" usage:"Include draft pages in the build"`,
watch: bool `usage:"Rebuild on file changes (polls every 5 seconds)"`,
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
site.base_url = "http://localhost:8080"
site.markdown_extensions = DEFAULT_MARKDOWN_EXTENSIONS
site.markdown_extensions = md.DEFAULT_EXTENSIONS
_flags: Flags
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
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.assets_dir != "" do site.assets_dir = flags.assets_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.watch {site.features += {.Watch}}
if flags.minify {site.features += {.Minify}}
site.markdown_extensions += parse_extension_list(flags.md_enable)
site.markdown_extensions -= 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.markdown_extensions += md.parse_extension_list(flags.md_enable)
site.markdown_extensions -= md.parse_extension_list(flags.md_disable)
}
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
}