mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
Compare commits
15 Commits
918100bf5d
...
84e28f8a1d
| Author | SHA1 | Date | |
|---|---|---|---|
| 84e28f8a1d | |||
| 9b732c4879 | |||
| 00b73c04ba | |||
| 3c149246be | |||
| d1ca36bbd2 | |||
| fea7144a4d | |||
| 7aa3cc7c74 | |||
| 91606a2223 | |||
| f48cc242a4 | |||
| 0ba29e43ed | |||
| b90496b5a2 | |||
| d23eec448b | |||
| 8de51ead39 | |||
| 3cbf765345 | |||
| 8a9f12d532 |
@@ -5,10 +5,10 @@ Thor is a static site generator written in [Odin](https://odin-lang.org), replac
|
||||
## Architecture
|
||||
|
||||
```
|
||||
thor.json ← site config (title, base_url, author, params)
|
||||
thor.json ← site config (title, base_url, author, params, sectionate)
|
||||
content/ ← markdown and HTML content files
|
||||
layouts/ ← Mustache templates + partials
|
||||
assets/ ← CSS (TailwindCSS source) and JS
|
||||
layouts/ ← Mustache templates + partials (including icons)
|
||||
assets/ ← CSS (Tufte-based, no build step) and JS
|
||||
public/ ← build output (generated)
|
||||
```
|
||||
|
||||
@@ -16,13 +16,16 @@ public/ ← build output (generated)
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `main.odin` | Entry point. Calls `init_site`, `walk_content`, `render_site` |
|
||||
| `main.odin` | Entry point. Sets `context.logger`, calls `init_site`, `walk_content`, `render_site` |
|
||||
| `site.odin` | `Site` struct (config + arena), `init_site`, `load_site_config`, `site_merge`, `site_allocator`, `destroy_site` |
|
||||
| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited) |
|
||||
| `content.odin` | `Page` struct, content walker, page loader, cmark integration, footnote/alert/emoji pipeline |
|
||||
| `footnotes.odin` | Footnote definition stripping (pre-cmark) + sidenote injection (post-cmark) |
|
||||
| `content.odin` | `Page` struct, content walker, page loader, cmark integration, full markdown pipeline |
|
||||
| `footnotes.odin` | Note definition stripping (pre-cmark) + sidenote/marginnote injection (post-cmark) |
|
||||
| `alerts.odin` | GitHub alert post-processor (`> [!CAUTION]` → styled blockquote) |
|
||||
| `emoji.odin` | Emoji shortcode expander (`:shrug:` → `¯\_(ツ)_/¯`) |
|
||||
| `emoji.odin` | Emoji shortcode expander (`:shrug:` → `¯\_(ツ)_/¯`), post-cmark |
|
||||
| `highlight.odin` | Post-cmark Tree-sitter syntax highlighter; loads grammars/queries from Helix, caches loaded grammars, reports syntax errors with file/line |
|
||||
| `tree_sitter.odin` | C FFI bindings for Tree-sitter (TSParser, TSQuery, TSQueryCursor, node traversal, dlopen/dlsym) |
|
||||
| `sectionate.odin` | `wrap_sections` proc — splits HTML at `<h2` into `<section>` wrappers |
|
||||
| `render.odin` | Mustache template rendering, all page types, RSS, sitemap, robots.txt, `load_partials` recursive scan |
|
||||
| `feed.odin` | RSS feed + sitemap XML generation |
|
||||
| `mustache/` | Vendored [odin-mustache](https://github.com/benjamindblock/odin-mustache) library |
|
||||
@@ -34,20 +37,21 @@ Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss,
|
||||
```
|
||||
thor.json → init_site → Site (config + Dynamic_Arena)
|
||||
↓
|
||||
content/ → walk_content → []Page (with body_html from cmark pipeline)
|
||||
content/ → walk_content → []Page (with body_html from pipeline)
|
||||
↓
|
||||
layouts/*.html → render_site (Mustache render_in_layout) → public/
|
||||
```
|
||||
|
||||
### Config system
|
||||
|
||||
`Site` has `params: json.Value` for arbitrary user-defined data from `thor.json`. Social links and other template-only data live under `"params"`:
|
||||
`Site` has `params: json.Value` for arbitrary user-defined data from `thor.json`. Social links and other template-only data live under `"params"`. `sectionate: bool` controls automatic `<section>` wrapping at `<h2>` boundaries.
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "...",
|
||||
"base_url": "...",
|
||||
"author": "...",
|
||||
"sectionate": true,
|
||||
"params": {
|
||||
"social": [
|
||||
{ "name": "github", "url": "...", "icon": "icons/github" }
|
||||
@@ -67,12 +71,27 @@ raw markdown
|
||||
→ expand_emoji (pre-cmark: :shortcode: → unicode)
|
||||
→ strip_definitions (pre-cmark: extract [^id]: definitions)
|
||||
→ cmark markdown_to_html (Unsafe mode for HTML passthrough)
|
||||
→ expand_emoji (post-cmark: :shortcode: → unicode, avoids cmark escape issues)
|
||||
→ inject_sidenotes (post-cmark: [^id] → <label><input><span> markup)
|
||||
→ inject_alerts (post-cmark: [!TYPE] blockquotes → styled alerts)
|
||||
→ highlight_code (post-cmark: tree-sitter per code block, with error reporting)
|
||||
→ wrap_sections (post-cmark: if site.sectionate, wraps content in <section> at <h2>)
|
||||
```
|
||||
|
||||
`.html` content files skip cmark entirely — body is used as-is.
|
||||
|
||||
### Syntax highlighting
|
||||
|
||||
Build-time highlighting via Tree-sitter C FFI. No client-side JavaScript.
|
||||
|
||||
- Grammars loaded via `dlopen` from Helix's compiled `.so` files
|
||||
- Highlight queries (`.scm`) loaded from Helix's runtime directory
|
||||
- Paths hardcoded in `tree_sitter.odin` (Nix store paths, Helix-version-dependent)
|
||||
- Capture names mapped to CSS classes: `keyword` → `.hl-keyword`, `constant.numeric.integer` → `.hl-constant-numeric-integer`, etc.
|
||||
- Atom-one-dark color theme in `main.css`
|
||||
- Failed grammar loads are cached (no retries) and logged via `log.warnf`
|
||||
- Syntax errors detected via `ts_node_has_error`, reported with file path and line number relative to code block
|
||||
|
||||
### Memory management
|
||||
|
||||
- `Site` owns a `mem.Dynamic_Arena`
|
||||
@@ -80,6 +99,7 @@ raw markdown
|
||||
- Config loading (flags + JSON) uses the arena allocator explicitly
|
||||
- `site_allocator(site)` returns the arena allocator for callers
|
||||
- `destroy_site` frees the arena
|
||||
- `main.odin` sets `context.logger = log.create_console_logger()` — without this, all `log.*` calls are silently dropped
|
||||
- **Not yet wired:** `context.allocator` is not set to the arena in `main.odin`, so rendering and content processing still use the heap allocator
|
||||
|
||||
## Building
|
||||
@@ -90,15 +110,17 @@ raw markdown
|
||||
nix develop
|
||||
# From blog root:
|
||||
odin run ./thor -- -drafts
|
||||
tailwindcss --input assets/css/main.css --output public/css/main.css --minify
|
||||
cp assets/css/main.css public/css/main.css
|
||||
cp assets/js/main.js public/js/main.js
|
||||
caddy run # serves public/ on blog.localhost
|
||||
```
|
||||
|
||||
No CSS build step — `main.css` is static Tufte-based CSS, no preprocessor or compiler needed.
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
nix build # runs thor + tailwindcss + cp js, outputs to ./result/
|
||||
nix build # runs thor + copies CSS/JS, outputs to ./result/
|
||||
```
|
||||
|
||||
### Tests
|
||||
@@ -117,7 +139,7 @@ Four modifications to `mustache/mustache.odin`:
|
||||
|
||||
2. **Layout partials** — `layout_template.partials = tmpl.partials` added so partials (`{{> nav}}`, `{{> footer}}`) work inside the base layout template.
|
||||
|
||||
3. **Inline partial rendering** — replaced `template_insert_partial` (which injected tokens into the main token list, breaking section iteration) with `template_render_partial` (which lexes the partial, temporarily swaps `tmpl.lexer`, and recursively processes via `template_process_tokens`). Fixes partials inside sections.
|
||||
3. **Inline partial rendering** — replaced `template_insert_partial` (which injected tokens into the main token list, breaking section iteration) with `template_render_partial` (which lexes the partial, temporarily swaps `tmpl.lexer`, and recursively processes via `template_process_tokens`). Fixes partials inside sections. Standalone indentation applied to partial source before lexing.
|
||||
|
||||
4. **Dynamic Names** — `{{>*key}}` support. When a partial token value starts with `*`, the remaining key is resolved from the data context stack, and the resolved string is used as the partial name. Enables per-item partial selection inside sections (e.g., `{{>* icon}}` resolves `icon` from each social link).
|
||||
|
||||
@@ -127,8 +149,15 @@ Extracted `template_process_tokens` from `template_eat_tokens` to separate ROOT
|
||||
|
||||
- cmark allocates via C malloc, not the arena. HTML output leaks until process exit.
|
||||
- CSS/JS cache busting uses manual `?v=N` query params instead of content hashing.
|
||||
- The `shrug` emoji has a backslash that may not display correctly.
|
||||
- `json.Value` params require 64-byte aligned arena (workaround for `dynamic_arena_allocator_proc` ignoring per-allocation alignment).
|
||||
- Tree-sitter grammar/query paths hardcoded in `tree_sitter.odin` (Nix store hashes, Helix-version-dependent).
|
||||
- `TSQueryCapture` needs explicit `_padding: u32` field for C ABI compatibility (40-byte sizeof).
|
||||
- `{{&content}}` in layout template must have no leading whitespace — `template_insert_content_into_layout` indents all lines by preceding whitespace, which corrupts `<pre>` blocks.
|
||||
- highlight.js removed; syntax highlighting is build-time only (no fallback if Tree-sitter fails).
|
||||
|
||||
## Design decisions
|
||||
|
||||
See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context isolation.
|
||||
|
||||
## TODO
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Hugo vs Thor: Why Thor Doesn't Need Shortcode Context Isolation
|
||||
|
||||
## Hugo's Shortcode Isolation
|
||||
|
||||
Hugo strictly separates shortcode context from layout template context. Shortcodes run during markdown rendering, before layouts. They get a limited context (`.Page`, `.Site`, `.Params`), not the full layout rendering state.
|
||||
|
||||
### Why Hugo does this
|
||||
|
||||
1. **Circular dependencies** — Hugo shortcodes output into `.Content`. Layouts read `.Content` and compute derived properties (`.TableOfContents`, `.WordCount`, `.ReadingTime`). If shortcodes could access these computed properties, you'd get circular dependencies (shortcode output → computed property → shortcode reads it).
|
||||
|
||||
2. **Multiple layouts/themes** — Hugo supports user-selectable themes and multiple layouts per section type. A shortcode must produce identical output regardless of which layout renders it. Isolating context guarantees portability.
|
||||
|
||||
3. **Shared mutable page object** — Hugo builds a rich `Page` object that both shortcodes and layouts access. Context isolation prevents shortcodes from mutating layout-visible state.
|
||||
|
||||
4. **Security** — Shortcodes live in content files (potentially user-authored). Full template access would let content authors inject arbitrary logic, access sensitive config, or break site structure.
|
||||
|
||||
5. **Caching** — Hugo caches shortcode output independently. Predictable context = predictable cache behavior.
|
||||
|
||||
## Why this doesn't apply to thor
|
||||
|
||||
- **No computed page properties** — The content Mustache pass runs on raw markdown (before cmark). The layout Mustache pass runs on finished HTML (after cmark). There's no shared `.Content` object or derived properties. The two passes are at different pipeline stages with no shared mutable state.
|
||||
|
||||
- **Single layout set** — Thor has one set of templates (`base.html` + page templates). No theme switching, no multiple layout variants per section. Portability across layouts isn't a concern.
|
||||
|
||||
- **Explicit data passing** — Each `mustache.render` call receives its own data struct. Content pass gets frontmatter + params. Layout pass gets page data + site config. No shared mutable object between them.
|
||||
|
||||
- **Single author** — Content is authored by the site owner. No untrusted user-generated content.
|
||||
|
||||
- **No caching** — Thor rebuilds from scratch every time. No cache invalidation concerns.
|
||||
|
||||
## Thor's approach
|
||||
|
||||
Thor runs Mustache on content **before** cmark as a pre-processing step, then runs Mustache on layout templates **after** cmark as a post-processing step. These are two independent `mustache.render` calls:
|
||||
|
||||
```
|
||||
content (markdown)
|
||||
→ mustache.render(content, content_data, partials) ← content pass
|
||||
→ cmark markdown_to_html
|
||||
→ inject_sidenotes / inject_alerts / highlight_code
|
||||
→ mustache.render_in_layout(template, page_data, layout, partials) ← layout pass
|
||||
→ final HTML
|
||||
```
|
||||
|
||||
The content pass can use Mustache variables, partials (`{{> ./file}}`), sections, and conditionals freely. The layout pass wraps the rendered content in the page chrome. They share nothing except the data we explicitly choose to pass.
|
||||
|
||||
## When this WOULD matter for thor
|
||||
|
||||
If thor ever adds:
|
||||
- Computed page properties (table of contents, reading time, word count)
|
||||
- Multiple selectable themes/layouts
|
||||
- User-generated content / multi-author support
|
||||
- Partial template caching
|
||||
|
||||
...then context isolation between the content and layout passes would become relevant. Until then, it's unnecessary complexity.
|
||||
@@ -1,19 +1,40 @@
|
||||
- [ ] add content-hash fingerprinting for tailwind cache busting.
|
||||
- [ ] Evaluate Tufte CSS — borrow sidenote CSS or replace TailwindCSS entirely
|
||||
- Option B: Steal Tufte's sidenote/margin-note CSS (adapt for dark theme), keep TailwindCSS
|
||||
- Option C: Full Tufte CSS — drop TailwindCSS, no build step, semantic HTML, customize for dark theme + Roboto
|
||||
- Our sidenote HTML pattern already matches Tufte's exactly
|
||||
- README.md
|
||||
- [ ] "Zero is beautiful"
|
||||
- [ ] Content-hash fingerprinting for CSS and JS cache busting
|
||||
- [ ] Enable configuration to opt-out of features, particular pre/post processors.
|
||||
- [ ] proper date/time/now object.
|
||||
- [ ] Footer isn't centered properly.
|
||||
- [ ] mustache data keys for opengraph, etc.
|
||||
- [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md
|
||||
- [x] Emoji shortcodes (`:shrug:` etc.) — 2 instances
|
||||
- [ ] Backslash in shrug not visible.
|
||||
- [x] Backslash in shrug not visible.
|
||||
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
|
||||
- [x] Nix build integration — main flake runs thor + tailwindcss instead of Hugo
|
||||
- [ ] Fix copy-to-clipboard button x-axis positioning inside code blocks
|
||||
- [ ] Content-hash fingerprinting for CSS and JS cache busting
|
||||
- [x] OpenGraph meta tags
|
||||
- [ ] Search up for `thor.json` files.
|
||||
- [x] copy all files from `static` to `public`.
|
||||
- [ ] ensure sidenote numbers render in display order and not in declaration order.
|
||||
- [x] Search up for `thor.json` files.
|
||||
- [ ] OpenGraph meta tags — verify all fields match production site
|
||||
- [ ] set opengraph tags / description automatically if unset. (Like hugo does)
|
||||
- [ ] Table of contents support.
|
||||
- [ ] Nav items should be active when the current page is selected.
|
||||
- [ ] Theme selector for syntax highlighting.
|
||||
- use http://github.com/helix-editor/helix/tree/master/runtime/themes) as a
|
||||
guide
|
||||
- [ ] Search for grammars in multiple places
|
||||
- [ ] Download missing grammars.
|
||||
- [ ] Syntax highlighting in production: CI (`nix build` on ubuntu-latest) has no grammar `.so`s and a machine-specific `QUERIES_PATH` nix store hash, so the deployed site renders unhighlighted. Provide grammars + queries as nix build inputs and pass paths to thor at runtime (env vars/flags).
|
||||
- [ ] Durable highlight paths: read `GRAPHS_PATH`/`QUERIES_PATH` from env vars set by the flake instead of hardcoded nix store hashes, so they survive `nix flake update` and let the grammar/query version-mismatch detector fire automatically.
|
||||
- [ ] Unit tests for highlighting helpers: `capture_name_to_css`, `escape_html`, `unescape_html`, `extract_query_token`, `helix_version_from_path`.
|
||||
- [ ] `<pre><code>` blocks need to set background to theme background,
|
||||
regardless of prefers-dark. (or use a different theme)
|
||||
- [ ] `-watch` flag
|
||||
- [x] basic poll loop
|
||||
- [ ] filesystem poll loop
|
||||
- [ ] event based
|
||||
- [ ] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode
|
||||
- [ ] commands
|
||||
- [ ] Add spall and find ways to reduce run time. (Getting close to 1/2sec here.)
|
||||
- [ ] Review every file in thor
|
||||
- [ ] Review alerts.odin
|
||||
- [ ] Review content.odin
|
||||
|
||||
+121
-33
@@ -3,13 +3,14 @@ package main
|
||||
import cm "vendor:commonmark"
|
||||
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
|
||||
Page_Type :: enum {
|
||||
Home,
|
||||
Post,
|
||||
Standalone,
|
||||
Post,
|
||||
Home,
|
||||
}
|
||||
|
||||
Page :: struct {
|
||||
@@ -29,19 +30,29 @@ Page :: struct {
|
||||
|
||||
// walk_content reads the content directory and returns all non-draft pages
|
||||
// (or all pages if include_drafts is true).
|
||||
walk_content :: proc(content_path: string, include_drafts: bool) -> []Page {
|
||||
pages: [dynamic]Page
|
||||
//
|
||||
// TODO: What is the lifetime of pages?
|
||||
walk_content :: proc(site: ^Site) -> []Page {
|
||||
content_path := site.content_dir
|
||||
include_drafts := .Drafts in site.features
|
||||
sectionate := .Sections in site.features
|
||||
|
||||
collect_home(&pages, content_path)
|
||||
collect_standalone(&pages, content_path)
|
||||
allocator := site_allocator(site)
|
||||
|
||||
pages := make([dynamic]Page, allocator)
|
||||
|
||||
collect_home(&pages, content_path, sectionate)
|
||||
collect_standalone(&pages, content_path, sectionate)
|
||||
|
||||
posts_path := fmt.tprintf("%s/posts", content_path)
|
||||
if os.exists(posts_path) {
|
||||
collect_posts(&pages, posts_path)
|
||||
collect_posts(&pages, posts_path, sectionate)
|
||||
}
|
||||
|
||||
if !include_drafts {
|
||||
filtered: [dynamic]Page
|
||||
if include_drafts {
|
||||
return pages[:]
|
||||
} else {
|
||||
filtered := make([dynamic]Page, allocator)
|
||||
for &page in pages {
|
||||
if !page.draft {
|
||||
append(&filtered, page)
|
||||
@@ -50,14 +61,12 @@ walk_content :: proc(content_path: string, include_drafts: bool) -> []Page {
|
||||
delete(pages)
|
||||
return filtered[:]
|
||||
}
|
||||
|
||||
return pages[:]
|
||||
}
|
||||
|
||||
collect_home :: proc(pages: ^[dynamic]Page, content_path: string) {
|
||||
collect_home :: proc(pages: ^[dynamic]Page, content_path: string, sectionate: bool) {
|
||||
html_path := fmt.tprintf("%s/index.html", content_path)
|
||||
if os.exists(html_path) {
|
||||
page, ok := load_page(html_path, .Home, "")
|
||||
page, ok := load_page(html_path, .Home, "", sectionate)
|
||||
if ok {
|
||||
page.permalink = "/"
|
||||
append(pages, page)
|
||||
@@ -67,7 +76,7 @@ collect_home :: proc(pages: ^[dynamic]Page, content_path: string) {
|
||||
|
||||
md_path := fmt.tprintf("%s/index.md", content_path)
|
||||
if os.exists(md_path) {
|
||||
page, ok := load_page(md_path, .Home, "")
|
||||
page, ok := load_page(md_path, .Home, "", sectionate)
|
||||
if ok {
|
||||
page.permalink = "/"
|
||||
append(pages, page)
|
||||
@@ -75,10 +84,10 @@ collect_home :: proc(pages: ^[dynamic]Page, content_path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
collect_standalone :: proc(pages: ^[dynamic]Page, content_path: string) {
|
||||
collect_standalone :: proc(pages: ^[dynamic]Page, content_path: string, sectionate: bool) {
|
||||
entries, err := os.read_all_directory_by_path(content_path, context.allocator)
|
||||
if err != nil {
|
||||
fmt.eprintfln("thor: cannot read %s: %v", content_path, err)
|
||||
log.warnf("thor: cannot read %s: %v", content_path, err)
|
||||
return
|
||||
}
|
||||
defer os.file_info_slice_delete(entries, context.allocator)
|
||||
@@ -95,17 +104,17 @@ collect_standalone :: proc(pages: ^[dynamic]Page, content_path: string) {
|
||||
}
|
||||
|
||||
slug := strip_extension(entry.name)
|
||||
page, ok := load_page(entry.fullpath, .Standalone, slug)
|
||||
page, ok := load_page(entry.fullpath, .Standalone, slug, sectionate)
|
||||
if ok {
|
||||
append(pages, page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect_posts :: proc(pages: ^[dynamic]Page, posts_path: string) {
|
||||
collect_posts :: proc(pages: ^[dynamic]Page, posts_path: string, sectionate: bool) {
|
||||
entries, err := os.read_all_directory_by_path(posts_path, context.allocator)
|
||||
if err != nil {
|
||||
fmt.eprintfln("thor: cannot read %s: %v", posts_path, err)
|
||||
log.warnf("thor: cannot read %s: %v", posts_path, err)
|
||||
return
|
||||
}
|
||||
defer os.file_info_slice_delete(entries, context.allocator)
|
||||
@@ -117,7 +126,7 @@ collect_posts :: proc(pages: ^[dynamic]Page, posts_path: string) {
|
||||
continue
|
||||
}
|
||||
slug := strip_extension(entry.name)
|
||||
page, ok := load_page(entry.fullpath, .Post, slug)
|
||||
page, ok := load_page(entry.fullpath, .Post, slug, sectionate)
|
||||
if ok {
|
||||
append(pages, page)
|
||||
}
|
||||
@@ -129,7 +138,7 @@ collect_posts :: proc(pages: ^[dynamic]Page, posts_path: string) {
|
||||
if !os.exists(index_path) {
|
||||
continue
|
||||
}
|
||||
page, ok := load_page(index_path, .Post, entry.name)
|
||||
page, ok := load_page(index_path, .Post, entry.name, sectionate)
|
||||
if ok {
|
||||
page.bundle_dir = entry.fullpath
|
||||
append(pages, page)
|
||||
@@ -143,13 +152,14 @@ load_page :: proc(
|
||||
file_path: string,
|
||||
page_type: Page_Type,
|
||||
slug: string,
|
||||
sectionate: bool,
|
||||
) -> (
|
||||
page: Page,
|
||||
ok: bool,
|
||||
) {
|
||||
data, err := os.read_entire_file_from_path(file_path, context.allocator)
|
||||
if err != nil {
|
||||
fmt.eprintfln("thor: cannot read %s: %v", file_path, err)
|
||||
log.warnf("thor: cannot read %s: %v", file_path, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -172,10 +182,14 @@ load_page :: proc(
|
||||
if strings.has_suffix(file_path, ".html") {
|
||||
page.body_html = strings.clone(body)
|
||||
} else {
|
||||
expanded := expand_emoji(body)
|
||||
clean_body, defs := strip_definitions(expanded)
|
||||
clean_body, sn_defs, mn_defs := strip_definitions(body)
|
||||
html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
|
||||
page.body_html = inject_alerts(inject_sidenotes(html, defs))
|
||||
html = expand_emoji(html)
|
||||
html = highlight_code(inject_alerts(inject_notes(html, sn_defs, mn_defs)), file_path)
|
||||
if sectionate {
|
||||
html = wrap_sections(html)
|
||||
}
|
||||
page.body_html = html
|
||||
}
|
||||
|
||||
switch page_type {
|
||||
@@ -203,25 +217,99 @@ strip_extension :: proc(name: string) -> string {
|
||||
return name[:dot]
|
||||
}
|
||||
|
||||
// copy_static_assets copies non-content files from the content root to the output directory.
|
||||
copy_static_assets :: proc(content_path: string, output_dir: string) {
|
||||
entries, err := os.read_all_directory_by_path(content_path, context.allocator)
|
||||
// copy_static_dir recursively copies all files from static_dir to output_dir.
|
||||
// Silently skips if static_dir doesn't exist.
|
||||
copy_static_dir :: proc(static_dir: string, output_dir: string) {
|
||||
if !os.exists(static_dir) {
|
||||
return
|
||||
}
|
||||
copy_static_recursive(static_dir, "", output_dir)
|
||||
}
|
||||
|
||||
copy_static_recursive :: proc(current: string, rel_prefix: string, output_dir: string) {
|
||||
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 entry.type != .Regular {
|
||||
rel := rel_prefix == "" ? entry.name : fmt.tprintf("%s/%s", rel_prefix, entry.name)
|
||||
switch entry.type {
|
||||
case .Regular:
|
||||
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 is_content_file(entry.name) {
|
||||
continue
|
||||
}
|
||||
if err := os.copy_file(dest, entry.fullpath); err != nil {
|
||||
log.warnf("thor: cannot copy %s: %v", entry.fullpath, err)
|
||||
}
|
||||
case .Directory:
|
||||
copy_static_recursive(entry.fullpath, rel, output_dir)
|
||||
case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dest := fmt.tprintf("%s/%s", output_dir, entry.name)
|
||||
// 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 with a warning suggesting they belong in static/.
|
||||
// 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_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:
|
||||
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
|
||||
}
|
||||
minified := minify_css(string(data))
|
||||
write_file(dest, minified)
|
||||
} else {
|
||||
if !strings.has_suffix(entry.name, ".css") {
|
||||
log.warnf("thor: %s is not a CSS file; consider moving it to static/", entry.fullpath)
|
||||
}
|
||||
if err := os.copy_file(dest, entry.fullpath); err != nil {
|
||||
fmt.eprintfln("thor: cannot copy %s: %v", entry.name, err)
|
||||
log.warnf("thor: cannot copy %s: %v", entry.fullpath, err)
|
||||
}
|
||||
}
|
||||
case .Directory:
|
||||
copy_assets_recursive(entry.fullpath, rel, output_dir, features)
|
||||
case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{ self
|
||||
, flake-parts
|
||||
, nixpkgs
|
||||
, nixpkgs-unstable
|
||||
inputs@{
|
||||
self,
|
||||
flake-parts,
|
||||
nixpkgs,
|
||||
nixpkgs-unstable,
|
||||
# , process-compose-flake
|
||||
, treefmt-nix
|
||||
treefmt-nix,
|
||||
}:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
imports = [
|
||||
@@ -27,7 +28,42 @@
|
||||
systems = [ "x86_64-linux" ];
|
||||
|
||||
perSystem =
|
||||
{ pkgs, system, inputs', ... }: {
|
||||
{
|
||||
pkgs,
|
||||
system,
|
||||
inputs',
|
||||
...
|
||||
}:
|
||||
let
|
||||
mkGrammarStaticLib = name: src: pkgs.stdenv.mkDerivation {
|
||||
inherit name src;
|
||||
dontConfigure = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
if [ -f src/scanner.cc ]; then
|
||||
$CXX -fPIC -c src/scanner.cc -o scanner.o
|
||||
elif [ -f src/scanner.c ]; then
|
||||
$CC -fPIC -c src/scanner.c -o scanner.o
|
||||
fi
|
||||
$CC -fPIC -c src/parser.c -o parser.o
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out/lib
|
||||
ar rcs $out/lib/lib${name}.a *.o
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
|
||||
html-grammar = mkGrammarStaticLib "tree-sitter-html"
|
||||
pkgs.tree-sitter-grammars.tree-sitter-html.src;
|
||||
css-grammar = mkGrammarStaticLib "tree-sitter-css"
|
||||
pkgs.tree-sitter-grammars.tree-sitter-css.src;
|
||||
in
|
||||
{
|
||||
_module.args.pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfree = true;
|
||||
@@ -48,15 +84,13 @@
|
||||
".env.local"
|
||||
];
|
||||
|
||||
|
||||
# Format nix files
|
||||
programs.nixpkgs-fmt.enable = true;
|
||||
programs.deadnix.enable = true;
|
||||
|
||||
# Format js, json, and yaml files
|
||||
programs.prettier.enable = true;
|
||||
settings.formatter.prettier =
|
||||
{
|
||||
settings.formatter.prettier = {
|
||||
excludes = [
|
||||
"public/**"
|
||||
"resources/js/modernizr.js"
|
||||
@@ -81,8 +115,13 @@
|
||||
buildInputs = [
|
||||
pkgs.git
|
||||
pkgs.cmark
|
||||
pkgs.tree-sitter
|
||||
html-grammar
|
||||
css-grammar
|
||||
];
|
||||
|
||||
LIBRARY_PATH = "${html-grammar}/lib:${css-grammar}/lib";
|
||||
|
||||
doCheck = true;
|
||||
checkPhase = ''
|
||||
runHook preCheck
|
||||
@@ -93,8 +132,6 @@
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
odin build . -o:speed -out:${pname}-keep
|
||||
echo "Listing filles..."
|
||||
ls .
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
@@ -105,18 +142,22 @@
|
||||
'';
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
odin
|
||||
ols
|
||||
cmark
|
||||
tree-sitter
|
||||
|
||||
# IDE
|
||||
unstable.helix
|
||||
typescript-language-server
|
||||
vscode-langservers-extracted
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export LIBRARY_PATH="${html-grammar}/lib:${css-grammar}/lib:$LIBRARY_PATH"
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
+62
-19
@@ -5,11 +5,18 @@ import cm "vendor:commonmark"
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
|
||||
// strip_definitions scans markdown text for footnote definitions ([^id]: text),
|
||||
// removes them, and returns the cleaned text plus a map of id→definition.
|
||||
Note_Kind :: enum {
|
||||
Sidenote,
|
||||
Marginnote,
|
||||
}
|
||||
|
||||
// strip_definitions scans markdown text for note definitions ([^id]: text for
|
||||
// sidenotes, [*id]: text for marginnotes), removes them, and returns the cleaned
|
||||
// text plus separate maps of id->definition for each kind.
|
||||
// Handles multi-line definitions with indented continuation lines.
|
||||
strip_definitions :: proc(body: string) -> (clean_body: string, defs: map[string]string) {
|
||||
defs = make(map[string]string)
|
||||
strip_definitions :: proc(body: string) -> (clean_body: string, sn_defs, mn_defs: map[string]string) {
|
||||
sn_defs = make(map[string]string)
|
||||
mn_defs = make(map[string]string)
|
||||
|
||||
lines := strings.split(body, "\n")
|
||||
output_lines: [dynamic]string
|
||||
@@ -19,7 +26,7 @@ strip_definitions :: proc(body: string) -> (clean_body: string, defs: map[string
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
|
||||
id, def_text, is_def := parse_def_line(line)
|
||||
id, def_text, kind, is_def := parse_def_line(line)
|
||||
if !is_def {
|
||||
append(&output_lines, line)
|
||||
i += 1
|
||||
@@ -39,8 +46,8 @@ strip_definitions :: proc(body: string) -> (clean_body: string, defs: map[string
|
||||
if len(next) == 0 {
|
||||
break
|
||||
}
|
||||
// Stop at new footnote definitions
|
||||
_, _, is_new_def := parse_def_line(next)
|
||||
// Stop at new note definitions
|
||||
_, _, _, is_new_def := parse_def_line(next)
|
||||
if is_new_def {
|
||||
break
|
||||
}
|
||||
@@ -53,7 +60,12 @@ strip_definitions :: proc(body: string) -> (clean_body: string, defs: map[string
|
||||
i += 1
|
||||
}
|
||||
|
||||
defs[id] = strings.join(def_parts[:], "\n")
|
||||
joined := strings.join(def_parts[:], "\n")
|
||||
if kind == .Marginnote {
|
||||
mn_defs[id] = joined
|
||||
} else {
|
||||
sn_defs[id] = joined
|
||||
}
|
||||
delete(def_parts)
|
||||
}
|
||||
|
||||
@@ -61,9 +73,18 @@ strip_definitions :: proc(body: string) -> (clean_body: string, defs: map[string
|
||||
return
|
||||
}
|
||||
|
||||
// parse_def_line checks if a line is a footnote definition: [^id]: text
|
||||
parse_def_line :: proc(line: string) -> (id: string, text: string, ok: bool) {
|
||||
if len(line) < 5 || line[0] != '[' || line[1] != '^' {
|
||||
// parse_def_line checks if a line is a note definition: [^id]: text (sidenote)
|
||||
// or [*id]: text (marginnote).
|
||||
parse_def_line :: proc(line: string) -> (id: string, text: string, kind: Note_Kind, ok: bool) {
|
||||
if len(line) < 5 || line[0] != '[' {
|
||||
return
|
||||
}
|
||||
|
||||
if line[1] == '^' {
|
||||
kind = .Sidenote
|
||||
} else if line[1] == '*' {
|
||||
kind = .Marginnote
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -90,10 +111,11 @@ is_indented :: proc(line: string) -> bool {
|
||||
return line[0] == ' ' || line[0] == '\t'
|
||||
}
|
||||
|
||||
// inject_sidenotes finds [^id] references in rendered HTML and replaces them
|
||||
// with sidenote markup. Each definition is rendered through cmark separately.
|
||||
inject_sidenotes :: proc(html: string, defs: map[string]string) -> string {
|
||||
if len(defs) == 0 {
|
||||
// inject_notes finds [^id] (sidenote) and [*id] (marginnote) references in
|
||||
// rendered HTML and replaces them with the appropriate margin markup. Each
|
||||
// definition is rendered through cmark separately.
|
||||
inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> string {
|
||||
if len(sn_defs) == 0 && len(mn_defs) == 0 {
|
||||
return html
|
||||
}
|
||||
|
||||
@@ -103,13 +125,20 @@ inject_sidenotes :: proc(html: string, defs: map[string]string) -> string {
|
||||
remaining := html
|
||||
|
||||
for {
|
||||
pos := strings.index(remaining, "[^")
|
||||
sn_pos := strings.index(remaining, "[^")
|
||||
mn_pos := strings.index(remaining, "[*")
|
||||
|
||||
is_margin := mn_pos >= 0 && (sn_pos < 0 || mn_pos < sn_pos)
|
||||
pos := sn_pos
|
||||
if is_margin {
|
||||
pos = mn_pos
|
||||
}
|
||||
if pos < 0 {
|
||||
append(&parts, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
// Append text before [^
|
||||
// Append text before the reference
|
||||
append(&parts, remaining[:pos])
|
||||
|
||||
close := strings.index(remaining[pos + 2:], "]")
|
||||
@@ -121,6 +150,10 @@ inject_sidenotes :: proc(html: string, defs: map[string]string) -> string {
|
||||
id := remaining[pos + 2 : pos + 2 + close]
|
||||
ref_end := pos + 2 + close + 1
|
||||
|
||||
defs := sn_defs
|
||||
if is_margin {
|
||||
defs = mn_defs
|
||||
}
|
||||
def_text, found := defs[id]
|
||||
if !found {
|
||||
// No definition found, leave as literal text
|
||||
@@ -133,13 +166,23 @@ inject_sidenotes :: proc(html: string, defs: map[string]string) -> string {
|
||||
def_html := cm.markdown_to_html_from_string(def_text, {.Unsafe})
|
||||
def_html = strip_p_tags(def_html)
|
||||
|
||||
sidenote := fmt.aprintf(
|
||||
note: string
|
||||
if is_margin {
|
||||
note = fmt.aprintf(
|
||||
`<label for="mn-%s" class="margin-toggle"></label><input type="checkbox" id="mn-%s" class="margin-toggle"><span class="marginnote">%s</span>`,
|
||||
id,
|
||||
id,
|
||||
def_html,
|
||||
)
|
||||
} else {
|
||||
note = fmt.aprintf(
|
||||
`<label for="fn-%s" class="margin-toggle sidenote-number"></label><input type="checkbox" id="fn-%s" class="margin-toggle"><span class="sidenote">%s</span>`,
|
||||
id,
|
||||
id,
|
||||
def_html,
|
||||
)
|
||||
append(&parts, sidenote)
|
||||
}
|
||||
append(&parts, note)
|
||||
|
||||
remaining = remaining[ref_end:]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#+feature dynamic-literals
|
||||
#+test
|
||||
package main
|
||||
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
|
||||
@(test)
|
||||
test_parse_def_line :: proc(t: ^testing.T) {
|
||||
// sidenote definition
|
||||
id, text, kind, ok := parse_def_line("[^foo]: bar baz")
|
||||
testing.expect(t, ok)
|
||||
testing.expect(t, id == "foo")
|
||||
testing.expect(t, text == "bar baz")
|
||||
testing.expect(t, kind == .Sidenote)
|
||||
|
||||
// marginnote definition
|
||||
id, text, kind, ok = parse_def_line("[*baz]: qux")
|
||||
testing.expect(t, ok)
|
||||
testing.expect(t, id == "baz")
|
||||
testing.expect(t, text == "qux")
|
||||
testing.expect(t, kind == .Marginnote)
|
||||
|
||||
// plain text is not a definition
|
||||
_, _, _, ok = parse_def_line("regular text")
|
||||
testing.expect(t, !ok)
|
||||
|
||||
// a bracket that is not a note (e.g. reference-link style) is ignored
|
||||
_, _, _, ok = parse_def_line("[link]: url")
|
||||
testing.expect(t, !ok)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_strip_definitions :: proc(t: ^testing.T) {
|
||||
body := "Intro[^a] and [*b].\n\n[^a]: a side\n\n[*b]: a margin\n"
|
||||
clean, sn_defs, mn_defs := strip_definitions(body)
|
||||
|
||||
// references stay; definitions are removed
|
||||
testing.expect(t, strings.contains(clean, "Intro[^a]"))
|
||||
testing.expect(t, strings.contains(clean, "[*b]"))
|
||||
testing.expect(t, !strings.contains(clean, "[^a]:"))
|
||||
testing.expect(t, !strings.contains(clean, "[*b]:"))
|
||||
|
||||
// routed to the correct map by sigil
|
||||
sa, sa_ok := sn_defs["a"]
|
||||
testing.expect(t, sa_ok)
|
||||
testing.expect(t, sa == "a side")
|
||||
|
||||
mb, mb_ok := mn_defs["b"]
|
||||
testing.expect(t, mb_ok)
|
||||
testing.expect(t, mb == "a margin")
|
||||
|
||||
// ids do not leak across maps
|
||||
_, leaked := sn_defs["b"]
|
||||
testing.expect(t, !leaked)
|
||||
_, leaked2 := mn_defs["a"]
|
||||
testing.expect(t, !leaked2)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_notes :: proc(t: ^testing.T) {
|
||||
html := "Text[^a] more [*b] end."
|
||||
sn_defs := map[string]string{"a" = "side note"}
|
||||
mn_defs := map[string]string{"b" = "margin note"}
|
||||
|
||||
out := inject_notes(html, sn_defs, mn_defs)
|
||||
|
||||
// sidenote: numbered, fn- prefix, .sidenote span, rendered text
|
||||
testing.expect(t, strings.contains(out, `for="fn-a" class="margin-toggle sidenote-number"></label>`))
|
||||
testing.expect(t, strings.contains(out, `class="sidenote"`))
|
||||
testing.expect(t, strings.contains(out, "side note"))
|
||||
|
||||
// marginnote: unnumbered, mn- prefix, .marginnote span, rendered text
|
||||
testing.expect(t, strings.contains(out, `for="mn-b" class="margin-toggle"></label>`))
|
||||
testing.expect(t, strings.contains(out, `class="marginnote"`))
|
||||
testing.expect(t, strings.contains(out, "margin note"))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_notes_no_defs :: proc(t: ^testing.T) {
|
||||
html := "No notes here."
|
||||
sn := make(map[string]string)
|
||||
mn := make(map[string]string)
|
||||
testing.expect(t, inject_notes(html, sn, mn) == html)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_notes_missing_ref :: proc(t: ^testing.T) {
|
||||
html := "Ref[^missing] and [*missing] end."
|
||||
sn := map[string]string{"other" = "x"}
|
||||
mn := map[string]string{"other2" = "y"}
|
||||
|
||||
out := inject_notes(html, sn, mn)
|
||||
|
||||
// references with no matching definition are left as literal text
|
||||
testing.expect(t, strings.contains(out, "[^missing]"))
|
||||
testing.expect(t, strings.contains(out, "[*missing]"))
|
||||
}
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
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 {
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
start := 0
|
||||
for i in 0..<len(s) {
|
||||
switch s[i] {
|
||||
case '&':
|
||||
if i > start do append(&parts, s[start:i])
|
||||
append(&parts, "&")
|
||||
start = i + 1
|
||||
case '<':
|
||||
if i > start do append(&parts, s[start:i])
|
||||
append(&parts, "<")
|
||||
start = i + 1
|
||||
case '>':
|
||||
if i > start do append(&parts, s[start:i])
|
||||
append(&parts, ">")
|
||||
start = i + 1
|
||||
case '"':
|
||||
if i > start do append(&parts, s[start:i])
|
||||
append(&parts, """)
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) do append(&parts, s[start:])
|
||||
if len(parts) == 0 do return s
|
||||
return strings.join(parts[:], "")
|
||||
}
|
||||
|
||||
unescape_html :: proc(s: string) -> string {
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
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 "&": replacement = "&"
|
||||
case "<": replacement = "<"
|
||||
case ">": replacement = ">"
|
||||
case """: replacement = "\""
|
||||
case "'", "'": replacement = "'"
|
||||
case: continue
|
||||
}
|
||||
if i > start do append(&parts, s[start:i])
|
||||
append(&parts, replacement)
|
||||
start = i + semi + 1
|
||||
}
|
||||
if start < len(s) do append(&parts, s[start:])
|
||||
if len(parts) == 0 do return s
|
||||
return strings.join(parts[:], "")
|
||||
}
|
||||
|
||||
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>`
|
||||
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
pos := 0
|
||||
|
||||
for {
|
||||
rel := strings.index(html[pos:], PREFIX)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
idx := pos + rel
|
||||
|
||||
if idx > pos {
|
||||
append(&parts, 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)
|
||||
append(&parts, fmt.tprintf(`<pre><code class="language-%s">%s</code></pre>`, lang, highlighted))
|
||||
|
||||
pos = end_idx + len(CODE_END)
|
||||
}
|
||||
|
||||
if pos < len(html) {
|
||||
append(&parts, html[pos:])
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return html
|
||||
}
|
||||
return strings.join(parts[:], "")
|
||||
}
|
||||
@@ -1,14 +1,29 @@
|
||||
package main
|
||||
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import "core:time"
|
||||
|
||||
main :: proc() {
|
||||
console_logger := log.create_console_logger()
|
||||
context.logger = console_logger
|
||||
defer log.destroy_console_logger(console_logger)
|
||||
|
||||
for {
|
||||
defer free_all(context.temp_allocator)
|
||||
site: Site
|
||||
init_site(&site, os.args)
|
||||
defer destroy_site(&site)
|
||||
// TODO: Make it so this isn't necessary
|
||||
context.allocator = site_allocator(&site)
|
||||
|
||||
pages := walk_content(site.content_dir, site.drafts)
|
||||
|
||||
pages := walk_content(&site)
|
||||
render_site(pages, site)
|
||||
|
||||
if !(.Watch in site.features) {
|
||||
break
|
||||
}
|
||||
time.sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
package main
|
||||
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
|
||||
PRESERVE_TAGS :: [?]string{"pre", "code", "textarea"}
|
||||
|
||||
Range :: struct {
|
||||
start: u32,
|
||||
end: u32,
|
||||
}
|
||||
|
||||
minify_html :: proc(source: string) -> string {
|
||||
gc := ensure_parser("html")
|
||||
if gc == nil {
|
||||
return source
|
||||
}
|
||||
|
||||
source_c := strings.clone_to_cstring(source)
|
||||
defer delete(source_c)
|
||||
|
||||
tree := ts_parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
|
||||
if tree == nil {
|
||||
return source
|
||||
}
|
||||
defer ts_tree_delete(tree)
|
||||
|
||||
root := ts_tree_root_node(tree)
|
||||
|
||||
if ts_node_has_error(root) {
|
||||
log.warnf("minify: HTML parse errors, skipping minification")
|
||||
return source
|
||||
}
|
||||
|
||||
comments: [dynamic]Range
|
||||
defer delete(comments)
|
||||
preserves: [dynamic]Range
|
||||
defer delete(preserves)
|
||||
|
||||
collect_html_ranges(root, source, &comments, &preserves)
|
||||
|
||||
sb := strings.builder_make()
|
||||
|
||||
ci := 0
|
||||
pi := 0
|
||||
i := 0
|
||||
last_written: u8 = 0
|
||||
|
||||
for i < len(source) {
|
||||
if pi < len(preserves) && u32(i) >= preserves[pi].start {
|
||||
p := preserves[pi]
|
||||
segment := source[i:p.end]
|
||||
strings.write_string(&sb, segment)
|
||||
if len(segment) > 0 {
|
||||
last_written = segment[len(segment)-1]
|
||||
}
|
||||
i = int(p.end)
|
||||
pi += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if ci < len(comments) && u32(i) >= comments[ci].start {
|
||||
i = int(comments[ci].end)
|
||||
ci += 1
|
||||
continue
|
||||
}
|
||||
|
||||
c := source[i]
|
||||
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
|
||||
j := i + 1
|
||||
for j < len(source) {
|
||||
c2 := source[j]
|
||||
if c2 != ' ' && c2 != '\t' && c2 != '\n' && c2 != '\r' {
|
||||
break
|
||||
}
|
||||
j += 1
|
||||
}
|
||||
next: u8 = 0
|
||||
if j < len(source) {
|
||||
next = source[j]
|
||||
}
|
||||
if last_written != '>' || next != '<' {
|
||||
strings.write_byte(&sb, ' ')
|
||||
last_written = ' '
|
||||
}
|
||||
i = j
|
||||
} else {
|
||||
strings.write_byte(&sb, c)
|
||||
last_written = c
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
collect_html_ranges :: proc(
|
||||
node: TSNode,
|
||||
source: string,
|
||||
comments: ^[dynamic]Range,
|
||||
preserves: ^[dynamic]Range,
|
||||
) {
|
||||
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))
|
||||
|
||||
if type_str == "comment" {
|
||||
append(comments, Range{
|
||||
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),
|
||||
})
|
||||
} 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),
|
||||
})
|
||||
} else {
|
||||
collect_html_ranges(child, source, comments, preserves)
|
||||
}
|
||||
} else {
|
||||
collect_html_ranges(child, source, comments, preserves)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html_tag_name :: proc(element: TSNode, 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)
|
||||
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)
|
||||
return source[start:end]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
is_preserve_tag :: proc(tag: string) -> bool {
|
||||
for t in PRESERVE_TAGS {
|
||||
if tag == t do return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
CSS_DELIMS :: [?]u8{'{', '}', ':', ';', ','}
|
||||
|
||||
is_css_delim :: proc(c: u8) -> bool {
|
||||
for d in CSS_DELIMS {
|
||||
if c == d do return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
minify_css :: proc(source: string) -> string {
|
||||
gc := ensure_parser("css")
|
||||
if gc == nil {
|
||||
return source
|
||||
}
|
||||
|
||||
source_c := strings.clone_to_cstring(source)
|
||||
defer delete(source_c)
|
||||
|
||||
tree := ts_parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
|
||||
if tree == nil {
|
||||
return source
|
||||
}
|
||||
defer ts_tree_delete(tree)
|
||||
|
||||
root := ts_tree_root_node(tree)
|
||||
|
||||
if ts_node_has_error(root) {
|
||||
log.warnf("minify: CSS parse errors, skipping minification")
|
||||
return source
|
||||
}
|
||||
|
||||
comments: [dynamic]Range
|
||||
defer delete(comments)
|
||||
|
||||
collect_css_comments(root, &comments)
|
||||
|
||||
sb := strings.builder_make()
|
||||
|
||||
ci := 0
|
||||
i := 0
|
||||
last_written: u8 = 0
|
||||
|
||||
for i < len(source) {
|
||||
if ci < len(comments) && u32(i) >= comments[ci].start {
|
||||
i = int(comments[ci].end)
|
||||
ci += 1
|
||||
continue
|
||||
}
|
||||
|
||||
c := source[i]
|
||||
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
|
||||
j := i + 1
|
||||
for j < len(source) {
|
||||
c2 := source[j]
|
||||
if c2 != ' ' && c2 != '\t' && c2 != '\n' && c2 != '\r' {
|
||||
break
|
||||
}
|
||||
j += 1
|
||||
}
|
||||
|
||||
next: u8 = 0
|
||||
if j < len(source) {
|
||||
next = source[j]
|
||||
}
|
||||
|
||||
if !is_css_delim(last_written) && !is_css_delim(next) {
|
||||
strings.write_byte(&sb, ' ')
|
||||
last_written = ' '
|
||||
}
|
||||
i = j
|
||||
} else {
|
||||
strings.write_byte(&sb, c)
|
||||
last_written = c
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
collect_css_comments :: proc(node: TSNode, 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" {
|
||||
append(comments, Range{
|
||||
start = ts_node_start_byte(child),
|
||||
end = ts_node_end_byte(child),
|
||||
})
|
||||
} else {
|
||||
collect_css_comments(child, comments)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -1,7 +1,7 @@
|
||||
#+feature dynamic-literals
|
||||
package main
|
||||
|
||||
import mustache "mustache"
|
||||
import "mustache"
|
||||
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
@@ -104,17 +104,26 @@ render_site :: proc(pages: []Page, config: Site) {
|
||||
continue
|
||||
}
|
||||
html := render_page_html(page, config)
|
||||
if .Minify in config.features {
|
||||
html = minify_html(html)
|
||||
}
|
||||
write_page(config.output_dir, page.permalink, html)
|
||||
}
|
||||
|
||||
// Render home page
|
||||
if has_home {
|
||||
home_html := render_home_html(home, pages, config)
|
||||
if .Minify in config.features {
|
||||
home_html = minify_html(home_html)
|
||||
}
|
||||
write_file(fmt.tprintf("%s/index.html", config.output_dir), home_html)
|
||||
}
|
||||
|
||||
// Render posts list page
|
||||
posts_html := render_posts_html(pages, config)
|
||||
if .Minify in config.features {
|
||||
posts_html = minify_html(posts_html)
|
||||
}
|
||||
write_page(config.output_dir, "/posts/", posts_html)
|
||||
|
||||
// Generate RSS feed
|
||||
@@ -125,8 +134,11 @@ render_site :: proc(pages: []Page, config: Site) {
|
||||
sitemap := generate_sitemap(pages, config.base_url)
|
||||
write_file(fmt.tprintf("%s/sitemap.xml", config.output_dir), sitemap)
|
||||
|
||||
// Copy static assets (avatar, favicon, etc.)
|
||||
copy_static_assets(config.content_dir, config.output_dir)
|
||||
// Copy static directory (favicon, CSS, images, etc.)
|
||||
copy_static_dir(config.static_dir, config.output_dir)
|
||||
|
||||
// Copy and maybe minify assets directory
|
||||
copy_assets_dir(config.assets_dir, config.output_dir, config.features)
|
||||
|
||||
// Generate robots.txt
|
||||
robots := fmt.aprintf("User-agent: *\nAllow: /\nSitemap: %s/sitemap.xml\n", config.base_url)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import "core:strings"
|
||||
|
||||
wrap_sections :: proc(html: string) -> string {
|
||||
H2 :: "<h2"
|
||||
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
pos := 0
|
||||
search_pos := 0
|
||||
|
||||
for {
|
||||
rel := strings.index(html[search_pos:], H2)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
idx := search_pos + rel
|
||||
|
||||
if idx > pos {
|
||||
append(&parts, "<section>")
|
||||
append(&parts, html[pos:idx])
|
||||
append(&parts, "</section>")
|
||||
}
|
||||
|
||||
pos = idx
|
||||
search_pos = idx + len(H2)
|
||||
}
|
||||
|
||||
if pos < len(html) {
|
||||
append(&parts, "<section>")
|
||||
append(&parts, html[pos:])
|
||||
append(&parts, "</section>")
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return html
|
||||
}
|
||||
return strings.join(parts[:], "")
|
||||
}
|
||||
@@ -3,42 +3,79 @@ package main
|
||||
import "core:encoding/json"
|
||||
import "core:flags"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:mem"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
|
||||
|
||||
Site :: struct {
|
||||
arena: mem.Dynamic_Arena,
|
||||
title: string,
|
||||
description: string,
|
||||
author: string,
|
||||
base_url: string,
|
||||
config_path: string,
|
||||
content_dir: string,
|
||||
static_dir: string,
|
||||
assets_dir: string,
|
||||
output_dir: string,
|
||||
layouts_dir: string,
|
||||
params: json.Value,
|
||||
features: bit_set[Feature],
|
||||
}
|
||||
|
||||
Feature :: enum {
|
||||
Sections,
|
||||
Drafts,
|
||||
Minify,
|
||||
Watch,
|
||||
}
|
||||
|
||||
Flags :: struct {
|
||||
config_path: string `args:"name=config"`,
|
||||
title: string,
|
||||
description: string,
|
||||
base_url: string `args:"name=base-url"`,
|
||||
content_dir: string `args:"name=content"`,
|
||||
static_dir: string `args:"name=static"`,
|
||||
assets_dir: string `args:"name=assets"`,
|
||||
output_dir: string `args:"name=output"`,
|
||||
layouts_dir: string,
|
||||
author: string,
|
||||
params: json.Value,
|
||||
sectionate: bool `args:"name=sections"`,
|
||||
drafts: bool `args:"name=drafts"`,
|
||||
watch: bool,
|
||||
minify: bool `args:"name=minify"`,
|
||||
}
|
||||
|
||||
init_site :: proc(site: ^Site, args: []string) {
|
||||
_flags: Site
|
||||
_flags: Flags
|
||||
mem.dynamic_arena_init(&site.arena, alignment = 64) // FIXME: This is a hack
|
||||
alloc := site_allocator(site)
|
||||
flags.parse_or_exit(&_flags, args, .Odin, alloc)
|
||||
|
||||
path := _flags.config_path
|
||||
if path == "" {
|
||||
found, ok := find_config("thor.json")
|
||||
if ok {
|
||||
path = found
|
||||
log.debugf("thor: using config %s", path)
|
||||
} else {
|
||||
path = "./thor.json"
|
||||
}
|
||||
|
||||
if load_site_config(site, path, alloc) {
|
||||
site_merge(site, _flags)
|
||||
} else {
|
||||
_flags.arena = site.arena
|
||||
site^ = _flags
|
||||
}
|
||||
|
||||
cfg, cfg_ok := load_site_config(path, alloc)
|
||||
if cfg_ok {
|
||||
merge_flags(&cfg, _flags)
|
||||
} else {
|
||||
cfg = _flags
|
||||
}
|
||||
|
||||
site_apply_flags(site, cfg)
|
||||
|
||||
// Determine config file's directory for relative defaults
|
||||
config_dir := "./"
|
||||
if idx := strings.last_index(path, "/"); idx >= 0 {
|
||||
@@ -46,9 +83,16 @@ init_site :: proc(site: ^Site, args: []string) {
|
||||
}
|
||||
|
||||
// Hardcoded defaults (lowest precedence)
|
||||
// TODO: Probably shouldn't use temp allocator here?
|
||||
if site.content_dir == "" {
|
||||
site.content_dir = fmt.tprintf("%s/content", config_dir)
|
||||
}
|
||||
if site.static_dir == "" {
|
||||
site.static_dir = fmt.tprintf("%s/static", config_dir)
|
||||
}
|
||||
if site.assets_dir == "" {
|
||||
site.assets_dir = fmt.tprintf("%s/assets", config_dir)
|
||||
}
|
||||
if site.output_dir == "" {
|
||||
site.output_dir = fmt.tprintf("%s/public", config_dir)
|
||||
}
|
||||
@@ -61,10 +105,10 @@ init_site :: proc(site: ^Site, args: []string) {
|
||||
}
|
||||
|
||||
load_site_config :: proc(
|
||||
config: ^Site,
|
||||
path: string,
|
||||
allocator := context.allocator,
|
||||
) -> (
|
||||
config: Flags,
|
||||
ok: bool,
|
||||
) {
|
||||
data, err := os.read_entire_file_from_path(path, allocator)
|
||||
@@ -72,9 +116,9 @@ load_site_config :: proc(
|
||||
return
|
||||
}
|
||||
|
||||
unmarshal_err := json.unmarshal_string(string(data), config, allocator = allocator)
|
||||
unmarshal_err := json.unmarshal_string(string(data), &config, allocator = allocator)
|
||||
if unmarshal_err != nil {
|
||||
fmt.eprintfln("thor: failed to parse %s: %v", path, unmarshal_err)
|
||||
log.warnf("thor: failed to parse %s: %v", path, unmarshal_err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,22 +126,56 @@ load_site_config :: proc(
|
||||
return
|
||||
}
|
||||
|
||||
site_merge :: proc(config: ^Site, flags: Site) {
|
||||
merge_flags :: proc(config: ^Flags, flags: Flags) {
|
||||
if flags.base_url != "" {
|
||||
config.base_url = flags.base_url
|
||||
}
|
||||
if flags.content_dir != "" {
|
||||
config.content_dir = flags.content_dir
|
||||
}
|
||||
if flags.static_dir != "" {
|
||||
config.static_dir = flags.static_dir
|
||||
}
|
||||
if flags.assets_dir != "" {
|
||||
config.assets_dir = flags.assets_dir
|
||||
}
|
||||
if flags.output_dir != "" {
|
||||
config.output_dir = flags.output_dir
|
||||
}
|
||||
if flags.drafts {
|
||||
config.drafts = true
|
||||
}
|
||||
if flags.watch {
|
||||
config.watch = true
|
||||
}
|
||||
if flags.sectionate {
|
||||
config.sectionate = true
|
||||
}
|
||||
if flags.minify {
|
||||
config.minify = true
|
||||
}
|
||||
config.config_path = flags.config_path
|
||||
}
|
||||
|
||||
site_apply_flags :: proc(site: ^Site, flags: Flags) {
|
||||
site.title = flags.title
|
||||
site.description = flags.description
|
||||
site.author = flags.author
|
||||
site.base_url = flags.base_url
|
||||
site.config_path = flags.config_path
|
||||
site.content_dir = flags.content_dir
|
||||
site.static_dir = flags.static_dir
|
||||
site.assets_dir = flags.assets_dir
|
||||
site.output_dir = flags.output_dir
|
||||
site.layouts_dir = flags.layouts_dir
|
||||
site.params = flags.params
|
||||
|
||||
if flags.sectionate {site.features += {.Sections}}
|
||||
if flags.drafts {site.features += {.Drafts}}
|
||||
if flags.watch {site.features += {.Watch}}
|
||||
if flags.minify {site.features += {.Minify}}
|
||||
}
|
||||
|
||||
site_allocator :: proc(site: ^Site) -> mem.Allocator {
|
||||
return mem.dynamic_arena_allocator(&site.arena)
|
||||
}
|
||||
@@ -106,3 +184,22 @@ destroy_site :: proc(site: ^Site) {
|
||||
mem.dynamic_arena_destroy(&site.arena)
|
||||
}
|
||||
|
||||
find_config :: proc(filename: string) -> (path: string, ok: bool) {
|
||||
dir, _ := os.get_working_directory(context.temp_allocator)
|
||||
|
||||
for {
|
||||
candidate := fmt.tprintf("%s/%s", dir, filename)
|
||||
if os.exists(candidate) {
|
||||
path = candidate
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
idx := strings.last_index(dir, "/")
|
||||
if idx <= 0 {
|
||||
return
|
||||
}
|
||||
dir = dir[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-34
@@ -34,16 +34,15 @@ test_load_site_config :: proc(t: ^testing.T) {
|
||||
)
|
||||
defer os.remove(path)
|
||||
|
||||
site: Site
|
||||
ok := load_site_config(&site, path, context.temp_allocator)
|
||||
cfg, ok := load_site_config(path, context.temp_allocator)
|
||||
|
||||
testing.expect(t, ok)
|
||||
testing.expect_value(t, site.title, "Test Site")
|
||||
testing.expect_value(t, site.description, "Test desc")
|
||||
testing.expect_value(t, site.base_url, "https://example.com")
|
||||
testing.expect_value(t, site.author, "Tester")
|
||||
testing.expect_value(t, cfg.title, "Test Site")
|
||||
testing.expect_value(t, cfg.description, "Test desc")
|
||||
testing.expect_value(t, cfg.base_url, "https://example.com")
|
||||
testing.expect_value(t, cfg.author, "Tester")
|
||||
|
||||
params, has_params := site.params.(json.Object)
|
||||
params, has_params := cfg.params.(json.Object)
|
||||
testing.expect(t, has_params)
|
||||
|
||||
social_val := params["social"]
|
||||
@@ -63,8 +62,7 @@ test_load_site_config :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_load_site_config_missing_file :: proc(t: ^testing.T) {
|
||||
site: Site
|
||||
ok := load_site_config(&site, "./nonexistent_thor_test.json", context.temp_allocator)
|
||||
_, ok := load_site_config("./nonexistent_thor_test.json", context.temp_allocator)
|
||||
testing.expect(t, !ok)
|
||||
}
|
||||
|
||||
@@ -73,8 +71,7 @@ test_load_site_config_invalid_json :: proc(t: ^testing.T) {
|
||||
path := write_temp_config("invalid", `{not valid json}`)
|
||||
defer os.remove(path)
|
||||
|
||||
site: Site
|
||||
ok := load_site_config(&site, path, context.temp_allocator)
|
||||
_, ok := load_site_config(path, context.temp_allocator)
|
||||
testing.expect(t, !ok)
|
||||
}
|
||||
|
||||
@@ -83,27 +80,26 @@ test_load_site_config_partial :: proc(t: ^testing.T) {
|
||||
path := write_temp_config("partial", `{"title":"Partial"}`)
|
||||
defer os.remove(path)
|
||||
|
||||
site: Site
|
||||
ok := load_site_config(&site, path, context.temp_allocator)
|
||||
cfg, ok := load_site_config(path, context.temp_allocator)
|
||||
|
||||
testing.expect(t, ok)
|
||||
testing.expect_value(t, site.title, "Partial")
|
||||
testing.expect_value(t, site.description, "")
|
||||
testing.expect_value(t, site.author, "")
|
||||
testing.expect(t, site.params == nil)
|
||||
testing.expect_value(t, cfg.title, "Partial")
|
||||
testing.expect_value(t, cfg.description, "")
|
||||
testing.expect_value(t, cfg.author, "")
|
||||
testing.expect(t, cfg.params == nil)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_site_merge_overrides :: proc(t: ^testing.T) {
|
||||
config := Site {
|
||||
config := Flags {
|
||||
base_url = "https://original.com",
|
||||
content_dir = "./content",
|
||||
}
|
||||
flags := Site {
|
||||
flags := Flags {
|
||||
base_url = "https://override.com",
|
||||
}
|
||||
|
||||
site_merge(&config, flags)
|
||||
merge_flags(&config, flags)
|
||||
|
||||
testing.expect_value(t, config.base_url, "https://override.com")
|
||||
testing.expect_value(t, config.content_dir, "./content")
|
||||
@@ -111,13 +107,13 @@ test_site_merge_overrides :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_site_merge_empty_flags_keep_config :: proc(t: ^testing.T) {
|
||||
config := Site {
|
||||
config := Flags {
|
||||
base_url = "https://keep.com",
|
||||
content_dir = "./keep",
|
||||
}
|
||||
flags := Site{}
|
||||
flags := Flags{}
|
||||
|
||||
site_merge(&config, flags)
|
||||
merge_flags(&config, flags)
|
||||
|
||||
testing.expect_value(t, config.base_url, "https://keep.com")
|
||||
testing.expect_value(t, config.content_dir, "./keep")
|
||||
@@ -125,38 +121,38 @@ test_site_merge_empty_flags_keep_config :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_site_merge_drafts_true :: proc(t: ^testing.T) {
|
||||
config := Site {
|
||||
config := Flags {
|
||||
drafts = false,
|
||||
}
|
||||
flags := Site {
|
||||
flags := Flags {
|
||||
drafts = true,
|
||||
}
|
||||
|
||||
site_merge(&config, flags)
|
||||
merge_flags(&config, flags)
|
||||
testing.expect(t, config.drafts)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_site_merge_drafts_false_preserves :: proc(t: ^testing.T) {
|
||||
config := Site {
|
||||
config := Flags {
|
||||
drafts = false,
|
||||
}
|
||||
flags := Site {
|
||||
flags := Flags {
|
||||
drafts = false,
|
||||
}
|
||||
|
||||
site_merge(&config, flags)
|
||||
merge_flags(&config, flags)
|
||||
testing.expect(t, !config.drafts)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_site_merge_config_path :: proc(t: ^testing.T) {
|
||||
config := Site{}
|
||||
flags := Site {
|
||||
config := Flags{}
|
||||
flags := Flags {
|
||||
config_path = "./custom/thor.json",
|
||||
}
|
||||
|
||||
site_merge(&config, flags)
|
||||
merge_flags(&config, flags)
|
||||
testing.expect_value(t, config.config_path, "./custom/thor.json")
|
||||
}
|
||||
|
||||
@@ -192,7 +188,7 @@ test_init_site_flag_overrides_default :: proc(t: ^testing.T) {
|
||||
init_site(&site, args)
|
||||
defer destroy_site(&site)
|
||||
|
||||
testing.expect(t, site.drafts)
|
||||
testing.expect(t, .Drafts in site.features)
|
||||
testing.expect_value(t, site.base_url, "https://flag.com")
|
||||
}
|
||||
|
||||
@@ -212,7 +208,7 @@ test_init_site_full_pipeline :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, site.title, "Pipeline Test")
|
||||
testing.expect_value(t, site.description, "Full")
|
||||
testing.expect_value(t, site.author, "Author")
|
||||
testing.expect(t, site.drafts)
|
||||
testing.expect(t, .Drafts in site.features)
|
||||
testing.expect_value(t, site.base_url, "https://config.com")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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 ---
|
||||
}
|
||||
Reference in New Issue
Block a user