Compare commits

...

15 Commits

Author SHA1 Message Date
Spencer Brower 5c689638b1 fix: Removed thor: prefixes from log messages. 2026-07-19 12:36:03 -04:00
Spencer Brower 398f0eb1b4 chore: Reformatted TODOS.md 2026-07-19 12:22:26 -04:00
Spencer Brower ac43ab1e5a chore: Url now stored on page. 2026-07-19 12:17:06 -04:00
Spencer Brower 4fc3eae64c refactor: Created Open_Graph struct for og data. 2026-07-19 12:04:27 -04:00
Spencer Brower 90f6466f9c refactor: Moved OpenGraph fields to a separate struct. 2026-07-19 11:48:46 -04:00
Spencer Brower ce0f662c92 fix: Renamed some data keys. 2026-07-19 11:35:19 -04:00
Spencer Brower 6f6c0dddd1 feat(mustache): Added spec-compliant lambda support. 2026-07-17 18:34:15 -04:00
Spencer Brower c0ff59513f fix: Fixed an issue with stars not showing up. 2026-07-17 17:46:41 -04:00
Spencer Brower 6e66709f05 fix: Fixed a bug with template selection. 2026-07-17 17:22:36 -04:00
Spencer Brower a41a7803a4 feat: Simplified templates. 2026-07-17 17:13:49 -04:00
Spencer Brower 66b5c5a934 fix(alerts): Fixed leaks 2026-07-17 16:43:42 -04:00
Spencer Brower ebb4ef691a feat(alerts): Fixed alert class names + css. 2026-07-17 16:30:26 -04:00
Spencer Brower 9d2b2c4395 test: Added tests for sectionate extension. 2026-07-17 15:16:45 -04:00
Spencer Brower d05459af52 test: Added tests for emoji.odin. 2026-07-17 15:03:49 -04:00
Spencer Brower c7a5ae2360 fix(emoji): Replaced raw bytes with human-readable symbols. 2026-07-17 14:57:24 -04:00
22 changed files with 1130 additions and 527 deletions
+1
View File
@@ -250,6 +250,7 @@ render(tmpl, data, partials) → render_nodes (walks flat node array against con
## Design decisions
You may never, *ever* remove `TODO:` or `FIXME:` comments. Those are for humans, not machines.
See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context isolation.
See `mustache/PARTIAL_INDENT.md` for whitespace handling analysis.
See `mustache/SPEC.md` for the original implementation specification.
+141
View File
@@ -0,0 +1,141 @@
# Plan: Computed Properties in Mustache
## Problem
`Year_Section` is a privileged, hardcoded struct in render.odin. Every section listing gets pages grouped by year — no choice. The grouping dimension (year) is baked into the code. Templates can't request different views of the same data.
## Solution: Computed Properties
Struct fields that are procs get called during mustache data resolution. When `lookup_in` resolves a field via reflection and finds a proc, it calls it and uses the return value. This is computed properties (Vue/Ember pattern), not spec lambdas (text transformers).
No mustache syntax changes. `{{#posts.by_year}}` is vanilla mustache — the magic is in data resolution, not template syntax.
## Template Usage
```handlebars
{{#posts.by_year}}
<h2>{{year}}</h2>
{{#posts}}
<li>{{title}}</li>
{{/posts}}
{{/posts.by_year}}
{{#posts.all}}
<li>{{title}}</li>
{{/posts.all}}
```
Context resolution handles naming naturally — `posts` inside a Year_Group (the `.posts` field) shadows the outer `posts` view object.
## Data Model
```odin
Year_Group :: struct {
year: string,
posts: [dynamic]Page_Context,
}
Pages_View :: struct {
all: [dynamic]Page_Context,
by_year: proc() -> [dynamic]Year_Group,
}
Section_Data :: struct {
using base: Base_Data,
page_title: string,
posts: Pages_View,
}
```
`by_year` is a closure that captures `all` and groups lazily. Grouping only happens when the template requests it.
## Implementation Steps
### 1. Mustache data layer (`mustache/data.odin`)
In `lookup_in` (or `resolve_name`), after resolving a field value via reflection:
```odin
// If the resolved value is a proc, call it
if value, ok := resolved.(proc() -> any); ok {
return value()
}
```
Need to handle proc detection generically — Odin has many proc types (different signatures, calling conventions). The computed properties we use are all zero-argument procs returning `any`.
Alternative: check via `reflect.Type_Info` if the value is a proc type, then call it through `reflect.CallProcedure` or transmute.
### 2. Odin closure challenge
Contextless procs can't capture variables. Regular closure procs capture context by reference. We need the proc to access the page list when called later by mustache.
Options:
- Store the data alongside the proc in the struct (e.g., `all` field on `Pages_View`). The proc accesses it via the struct instance.
- Use `context.allocator` to store captured data that the closure references.
- Pass the struct as an implicit `self` parameter (method-style).
The simplest approach: the closure captures a pointer to the data, which is arena-allocated and alive during rendering.
### 3. Build `Pages_View` in `render_section`
```odin
all_pages := make([dynamic]Page_Context)
for page in site.pages {
if page.section != section || page._is_index { continue }
append(&all_pages, build_page_context(page))
}
view := Pages_View{
all = all_pages,
by_year = group_by_year_closure, // captures all_pages
}
```
### 4. Remove `Year_Section` and `year_sections`
- Delete `Year_Section` struct
- Remove `year_sections` from `Section_Data`
- Remove year grouping logic from `render_section`
- Delete `get_year` helper (or move it into the closure)
### 5. Update templates
`posts_index.html`:
```handlebars
{{#posts.by_year}}
<section>
<h2>{{year}}</h2>
<ul class="post-list">
{{#posts}} <li>...</li> {{/posts}}
</ul>
</section>
{{/posts.by_year}}
```
Home page could use the same pattern:
```handlebars
{{#pages.all}}
<li>...</li>
{{/pages.all}}
```
### 6. Tests
- Verify `by_year` proc is called lazily (only when template requests it)
- Verify year groups contain correct pages
- Verify flat list (`all`) works independently
## Prerequisite
**Step 1 (mustache data layer) must be done first.** This is the foundation — without proc-calling in data resolution, none of the rest works.
## Future Extensions
Once computed properties work, they're available everywhere:
- `posts.featured` — filter by starred
- `posts.recent(n)` — most recent N posts (if we support params)
- `site.tags` — all unique tags across posts
- `pages.by_section("posts")` — filter by section
These are all zero-argument procs on view structs. No mustache changes needed beyond step 1.
+63 -19
View File
@@ -1,22 +1,45 @@
- 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
## 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.
- [ ] Only publish referenced assets.
- [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely
- [ ] Use spall to find ways to reduce run time.
## Memory Management
- [ ] Not sure whether to use temp allocator or site_allocator in opengraph.odin.
- [ ] Not sure whether to use temp allocator or site_allocator in `site_load_content`.
- [ ] Might not need to allocate in `strip_html_tags`
## Markdown
- [ ] Add overloads for every extension - accept ^strings.Builder.
- [ ] Add conventional (Hugo style) footnotes option.
- [ ] Add heading ids as a default on extension.
## General
- [ ] Integrity hash
- Allows users to verify their output didn't change after upgrading to a new version
- [ ] Content-hash fingerprinting for CSS and JS cache busting
- [ ] Avoid `json.Value` / `json.Object` where possible.
- [ ] running ./thor/thor still logs the debug message: using config /home/spencer/github.com/sbrow.github.io/thor.json
- wrong cwd?
- [ ] Clean up the default layouts
- [ ] Add `-production` flag
- sets `-minify`
- [ ] Open Graph
- [x] mustache data keys for opengraph, etc.
- [ ] OpenGraph meta tags — verify all fields match production site
- [ ] set opengraph tags / description automatically if unset. (Like hugo does)
- [ ] can't set avatar.jpg directly in `og_init`.
- [ ] Author should be a struct adhering to https://schema.org/author
- [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
- [ ] follow symlinks in `scan_content`?
- [ ] ensure sidenote numbers render in display order and not in declaration order.
- [ ] OpenGraph meta tags — verify all fields match production site
- [ ] Add Opt-in deflist support.
- [ ] We need to be able to do `Year_Section` in a non-magical, unpriviledged way.
- [ ] set opengraph tags / description automatically if unset. (Like hugo does)
- [ ] We need to be able to do `Year_Section` in a non-magical, unprivileged way. See [PLAN.md](PLAN.md) for computed properties approach.
- [ ] Table of contents support.
- [ ] Nav items should be active when the current page is selected.
- [ ] Theme selector for syntax highlighting.
@@ -36,14 +59,29 @@
- [ ] 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
- [ ] 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.
- [ ] Mount content in VFS
- [ ] commands
- [ ] `build` alias of default
- [ ] `init` set up new project
- [ ] Use spall to find ways to reduce run time.
- [ ] `new site` set up new project
- [ ] warn/error when unknown key used in mustache.
- [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
## Notes
from the [Hugo docs](https://gohugo.io/quick-reference/glossary/#default-sort-order)
default sort order
: The default sort order for page collections, used when no other criteria are set, follows this priority:
1. weight (ascending)
2. date (descending)
3. linkTitle falling back to title (ascending)
4. logical path (ascending)
## Code Review
A human should manually review every file in the project. AI cannot complete
these tasks.
- [ ] Review every file in thor
- [ ] Review assets.odin
- [ ] Review content.odin
@@ -53,14 +91,20 @@
- [ ] Review main.odin
- [ ] Review minify.odin
- [ ] Review `markdown/`
- [ ] Review alerts.odin
- [ ] Review emoji.odin
- [x] Review alerts.odin
- [x] Review alerts_test.odin
- [x] Review emoji.odin
- [x] Review emoji_test.odin
- [ ] Review footnotes.odin
- [ ] Review footnotes_test.odin
- [ ] Review highlight.odin
- [ ] Review markdown.odin
- [ ] Review sectionate.odin
- [x] Review sectionate.odin
- [x] Review sectionate_test.odin
- [x] Review opengraph.odin
- [ ] Review render.odin
- [x] Review site.odin
- [ ] Review treesitter/treesitter.odin
- [ ] Review vfs.odin
- [ ] Review procs
- [ ] markdown.transform_alert
+3 -3
View File
@@ -16,7 +16,7 @@ copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature
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)
log.warnf("cannot create %s: %v", dest[:idx], err)
continue
}
}
@@ -28,11 +28,11 @@ copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature
}
} 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)
log.warnf("cannot write %s: %v", dest, err)
}
} else {
if err := os.copy_file(dest, entry.fs_path); err != nil {
log.warnf("thor: cannot copy %s: %v", entry.fs_path, err)
log.warnf("cannot copy %s: %v", entry.fs_path, err)
}
}
}
+10 -2
View File
@@ -13,6 +13,7 @@ Page :: struct {
slug: string,
layout: string,
permalink: string,
url: string,
title: string,
description: string,
date: string,
@@ -28,6 +29,10 @@ Page :: struct {
site_load_content :: proc(site: ^Site) {
site.pages = make([dynamic]Page, 0, 8, site_allocator(site))
scan_content(site, site.content_dir, "")
for &page in site.pages {
page.url = fmt.tprintf("%s%s", site.base_url, page.permalink)
}
}
// scan_content walks the content directory. At the root level (section=""),
@@ -36,7 +41,7 @@ site_load_content :: proc(site: ^Site) {
scan_content :: proc(site: ^Site, dir: string, section: string) {
entries, err := os.read_all_directory_by_path(dir, context.allocator)
if err != nil {
log.warnf("thor: cannot read %s: %v", dir, err)
log.warnf("cannot read %s: %v", dir, err)
return
}
defer os.file_info_slice_delete(entries, context.allocator)
@@ -91,6 +96,9 @@ infer_layout :: proc(section: string, is_index: bool) -> string {
return fmt.tprintf("%s_index", section)
}
if section != "" {
if len(section) > 1 && section[len(section) - 1] == 's' {
return section[:len(section) - 1]
}
return section
}
return "page"
@@ -108,7 +116,7 @@ load_page :: proc(
) {
data, err := os.read_entire_file_from_path(file_path, context.allocator)
if err != nil {
log.warnf("thor: cannot read %s: %v", file_path, err)
log.warnf("cannot read %s: %v", file_path, err)
return
}
+2 -2
View File
@@ -5,10 +5,10 @@
{{&body}}
</header>
<ul>
{{#list_pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
{{#pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
</li>
{{/list_pages}}
{{/pages}}
</ul>
</main>
{{/content}}
+9 -9
View File
@@ -1,9 +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}}">
<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="{{og.locale}}">
<meta property="og:type" content="{{og.type}}">
{{#og.is_article}}<meta property="article:section" content="{{og.section}}">
<meta property="article:published_time" content="{{og.published_time}}">
{{/og.is_article}}<meta property="og:image" content="{{og.image}}">
+2 -2
View File
@@ -3,7 +3,7 @@
<main>
<h1>{{page_title}}</h1>
{{&body}}
{{#year_sections}}
{{#by_year}}
<section>
<h2>{{year}}</h2>
<ul>
@@ -13,7 +13,7 @@
{{/posts}}
</ul>
</section>
{{/year_sections}}
{{/by_year}}
</main>
{{/content}}
{{/base}}
+5 -7
View File
@@ -40,18 +40,16 @@ generate_rss :: proc(site: ^Site) -> string {
fmt.aprintf(
`<item>
<title>%s</title>
<link>%s%s</link>
<link>%s</link>
<pubDate>%s</pubDate>
<guid>%s%s</guid>
<guid>%s</guid>
<description>%s</description>
</item>
`,
xml_escape(page.title),
site.base_url,
page.permalink,
page.url,
pub_date,
site.base_url,
page.permalink,
page.url,
xml_escape(page.body_html),
),
)
@@ -78,7 +76,7 @@ generate_sitemap :: proc(site: ^Site) -> string {
}
strings.write_string(
&sb,
fmt.aprintf("<url><loc>%s%s</loc>%s</url>\n", site.base_url, page.permalink, lastmod),
fmt.aprintf("<url><loc>%s</loc>%s</url>\n", page.url, lastmod),
)
}
+1 -1
View File
@@ -36,7 +36,7 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
value, err := json.parse_string(json_str, spec = .JSON)
if err != nil {
fmt.eprintfln("thor: failed to parse frontmatter JSON: %v", err)
fmt.eprintfln("failed to parse frontmatter JSON: %v", err)
return
}
defer json.destroy_value(value)
+34 -41
View File
@@ -1,23 +1,14 @@
#+feature dynamic-literals
package markdown
import "core:fmt"
import "core:strings"
ALERT_STYLES: map[string]string = {
"note" = "border-l-blue-500 bg-blue-950/30 text-slate-300",
"tip" = "border-l-green-500 bg-green-950/30 text-slate-300",
"important" = "border-l-purple-500 bg-purple-950/30 text-slate-300",
"warning" = "border-l-yellow-500 bg-yellow-950/30 text-slate-300",
"caution" = "border-l-red-500 bg-red-950/30 text-slate-300",
}
ALERT_EMOJIS: map[string]string = {
"note" = "\xe2\x84\xb9\xef\xb8\x8f",
"tip" = "\xf0\x9f\x92\xa1",
"important" = "\xe2\x9d\x97",
"warning" = "\xe2\x9a\xa0\xef\xb8\x8f",
"caution" = "\xe2\x9d\x97",
"note" = "",
"tip" = "💡",
"important" = "",
"warning" = "⚠️",
"caution" = "",
}
inject_alerts :: proc(html: string) -> string {
@@ -43,7 +34,7 @@ inject_alerts :: proc(html: string) -> string {
bq := remaining[bq_start:bq_end]
strings.write_string(&sb, remaining[:bq_start])
strings.write_string(&sb, transform_alert(bq))
transform_alert(&sb, bq)
remaining = remaining[bq_end:]
}
@@ -51,55 +42,57 @@ inject_alerts :: proc(html: string) -> string {
return strings.to_string(sb)
}
transform_alert :: proc(bq: string) -> string {
// Skip <blockquote> tag and whitespace
transform_alert :: proc(sb: ^strings.Builder, bq: string) {
pos := len("<blockquote>")
for pos < len(bq) && (bq[pos] == '\n' || bq[pos] == '\r' || bq[pos] == ' ' || bq[pos] == '\t') {
for pos < len(bq) &&
(bq[pos] == '\n' || bq[pos] == '\r' || bq[pos] == ' ' || bq[pos] == '\t') {
pos += 1
}
// Check for <p>[!
if pos + 4 >= len(bq) || bq[pos] != '<' || bq[pos + 1] != 'p' || bq[pos + 2] != '>' ||
bq[pos + 3] != '[' || bq[pos + 4] != '!' {
return bq
if pos + 4 >= len(bq) ||
bq[pos] != '<' ||
bq[pos + 1] != 'p' ||
bq[pos + 2] != '>' ||
bq[pos + 3] != '[' ||
bq[pos + 4] != '!' {
strings.write_string(sb, bq)
return
}
// Extract alert type until ]
close := strings.index(bq[pos + 5:], "]")
if close < 0 {
return bq
strings.write_string(sb, bq)
return
}
type_raw := bq[pos + 5 : pos + 5 + close]
type_lower := strings.to_lower(type_raw)
type_raw := bq[pos + 5:pos + 5 + close]
type_lower := strings.to_lower(type_raw, context.temp_allocator)
style, has_style := ALERT_STYLES[type_lower]
emoji, has_emoji := ALERT_EMOJIS[type_lower]
if !has_style || !has_emoji {
return bq
emoji, found := ALERT_EMOJIS[type_lower]
if !found {
strings.write_string(sb, bq)
return
}
// Position after ]
after_type := pos + 5 + close + 1
// Skip optional + or -
content_start := after_type
if content_start < len(bq) && (bq[content_start] == '+' || bq[content_start] == '-') {
content_start += 1
}
// Skip space after marker
if content_start < len(bq) && bq[content_start] == ' ' {
content_start += 1
}
// Rebuild: styled blockquote + bold title paragraph + rest
rest := bq[content_start:]
return fmt.aprintf(
`<blockquote class="alert %s rounded-r py-2">
<p class="font-bold mb-1">%s %s`,
style,
emoji,
rest,
)
strings.write_string(sb, `<blockquote class="alert alert-`)
strings.write_string(sb, type_lower)
strings.write_string(sb, `">`)
strings.write_string(sb, "\n")
strings.write_string(sb, `<p class="alert-title">`)
strings.write_string(sb, emoji)
strings.write_string(sb, " ")
strings.write_string(sb, rest)
}
+104
View File
@@ -0,0 +1,104 @@
#+feature dynamic-literals
#+test
package markdown
import "core:testing"
@(test)
test_alerts_render_properly :: proc(t: ^testing.T) {
testing.expect_value(
t,
inject_alerts("<blockquote>\n<p>[!NOTE] Hello.</p>\n</blockquote>"),
`<blockquote class="alert alert-note">
<p class="alert-title">️ Hello.</p>
</blockquote>`,
)
testing.expect_value(
t,
inject_alerts("<blockquote>\n<p>[!TIP] Be smart.</p>\n</blockquote>"),
`<blockquote class="alert alert-tip">
<p class="alert-title">💡 Be smart.</p>
</blockquote>`,
)
testing.expect_value(
t,
inject_alerts("<blockquote>\n<p>[!CAUTION] Danger!</p>\n</blockquote>"),
`<blockquote class="alert alert-caution">
<p class="alert-title">❗ Danger!</p>
</blockquote>`,
)
}
@(test)
test_non_alerts_pass_through_inject_alerts :: proc(t: ^testing.T) {
testing.expect_value(
t,
inject_alerts("<blockquote>\n<p>Just a quote.</p>\n</blockquote>"),
"<blockquote>\n<p>Just a quote.</p>\n</blockquote>",
)
testing.expect_value(
t,
inject_alerts("<blockquote>\n<p>[!UNKNOWN] Nope.</p>\n</blockquote>"),
"<blockquote>\n<p>[!UNKNOWN] Nope.</p>\n</blockquote>",
)
testing.expect_value(
t,
inject_alerts("<p>No blockquote here.</p>"),
"<p>No blockquote here.</p>",
)
testing.expect_value(t, inject_alerts(""), "")
}
@(test)
test_case_insensitive_alerts_render :: proc(t: ^testing.T) {
testing.expect_value(
t,
inject_alerts("<blockquote>\n<p>[!Note] Mixed case.</p>\n</blockquote>"),
`<blockquote class="alert alert-note">
<p class="alert-title">️ Mixed case.</p>
</blockquote>`,
)
testing.expect_value(
t,
inject_alerts("<blockquote>\n<p>[!NOTE] Upper case.</p>\n</blockquote>"),
`<blockquote class="alert alert-note">
<p class="alert-title">️ Upper case.</p>
</blockquote>`,
)
}
@(test)
test_multiple_alerts_render_together :: proc(t: ^testing.T) {
testing.expect_value(
t,
inject_alerts(
"<blockquote>\n<p>[!NOTE] First.</p>\n</blockquote>\n<blockquote>\n<p>[!TIP] Second.</p>\n</blockquote>",
),
`<blockquote class="alert alert-note">
<p class="alert-title">️ First.</p>
</blockquote>
<blockquote class="alert alert-tip">
<p class="alert-title">💡 Second.</p>
</blockquote>`,
)
testing.expect_value(
t,
inject_alerts(
"<blockquote>\n<p>Regular.</p>\n</blockquote>\n<blockquote>\n<p>[!WARNING] Alert!</p>\n</blockquote>",
),
`<blockquote>
<p>Regular.</p>
</blockquote>
<blockquote class="alert alert-warning">
<p class="alert-title">⚠️ Alert!</p>
</blockquote>`,
)
}
+367 -364
View File
@@ -5,384 +5,382 @@ import "core:strings"
EMOJIS: map[string]string = {
// People
"smile" = "\xf0\x9f\x98\x84",
"laughing" = "\xf0\x9f\x98\x86",
"blush" = "\xf0\x9f\x98\x8a",
"smiley" = "\xf0\x9f\x98\x83",
"wink" = "\xf0\x9f\x98\x89",
"joy" = "\xf0\x9f\xa4\xa3",
"rofl" = "\xf0\x9f\xa4\xa3",
"relaxed" = "\xe2\x98\xba\xef\xb8\x8f",
"thinking" = "\xf0\x9f\xa4\x94",
"neutral_face" = "\xf0\x9f\x98\x90",
"expressionless" = "\xf0\x9f\x98\x91",
"no_mouth" = "\xf0\x9f\x98\xb6",
"rolling_eyes" = "\xf0\x9f\x99\x84",
"smirk" = "\xf0\x9f\x98\x8f",
"persevere" = "\xf0\x9f\x98\xa3",
"disappointed_relieved" = "\xf0\x9f\x98\xa5",
"open_mouth" = "\xf0\x9f\x98\xae",
"zipper_mouth_face" = "\xf0\x9f\xa4\x90",
"hushed" = "\xf0\x9f\x98\xaf",
"sleepy" = "\xf0\x9f\x98\xaa",
"tired_face" = "\xf0\x9f\x98\xab",
"sleeping" = "\xf0\x9f\x98\xb4",
"relieved" = "\xf0\x9f\x98\x8c",
"stuck_out_tongue" = "\xf0\x9f\x98\x9b",
"stuck_out_tongue_winking_eye" = "\xf0\x9f\x98\x9c",
"stuck_out_tongue_closed_eyes" = "\xf0\x9f\x98\x9d",
"drooling_face" = "\xf0\x9f\xa4\xa4",
"unamused" = "\xf0\x9f\x98\x92",
"sweat" = "\xf0\x9f\x98\x85",
"pensive" = "\xf0\x9f\x98\x94",
"confused" = "\xf0\x9f\x98\x95",
"upside_down_face" = "\xf0\x9f\x99\x83",
"money_mouth_face" = "\xf0\x9f\xa4\x91",
"astonished" = "\xf0\x9f\x98\xb2",
"white_frowning_face" = "\xe2\x98\xb9\xef\xb8\x8f",
"slightly_sad_face" = "\xe2\x98\xb9\xef\xb8\x8f",
"confounded" = "\xf0\x9f\x98\x96",
"disappointed" = "\xf0\x9f\x98\x9e",
"worried" = "\xf0\x9f\x98\x9f",
"triumph" = "\xf0\x9f\x98\xa4",
"cry" = "\xf0\x9f\x98\xa2",
"sob" = "\xf0\x9f\x98\xad",
"frowning" = "\xf0\x9f\x98\xa6",
"frowning_face" = "\xf0\x9f\x99\x81",
"anguished" = "\xf0\x9f\x98\xa7",
"fearful" = "\xf0\x9f\x98\xa8",
"weary" = "\xf0\x9f\x98\xa9",
"grimacing" = "\xf0\x9f\x98\xac",
"cold_sweat" = "\xf0\x9f\x98\xb0",
"scream" = "\xf0\x9f\x98\xb1",
"flushed" = "\xf0\x9f\x98\xb3",
"dizzy_face" = "\xf0\x9f\x98\xb5",
"rage" = "\xf0\x9f\x98\xa1",
"angry" = "\xf0\x9f\x98\xa0",
"innocent" = "\xf0\x9f\x98\x87",
"cowboy_hat_face" = "\xf0\x9f\xa4\xa0",
"clown_face" = "\xf0\x9f\xa4\xa1",
"mask" = "\xf0\x9f\x98\xb7",
"thermometer_face" = "\xf0\x9f\xa4\x92",
"head_bandage" = "\xf0\x9f\xa4\x95",
"nauseated_face" = "\xf0\x9f\xa4\xa2",
"sneezing_face" = "\xf0\x9f\xa4\xa7",
"smiling_imp" = "\xf0\x9f\x98\x88",
"imp" = "\xf0\x9f\x91\xbf",
"shrug" = "\xc2\xaf\\_(\xe3\x83\x84)_/\xc2\xaf",
"facepalm" = "\xf0\x9f\xa4\xa6",
"facepunch" = "\xf0\x9f\x91\x8a",
"wave" = "\xf0\x9f\x91\x8b",
"ok_hand" = "\xf0\x9f\x91\x8c",
"thumbsup" = "\xf0\x9f\x91\x8d",
"thumbsdown" = "\xf0\x9f\x91\x8e",
"clap" = "\xf0\x9f\x91\x8f",
"pray" = "\xf0\x9f\x99\x8f",
"point_up" = "\xe2\x98\x9d\xef\xb8\x8f",
"point_down" = "\xf0\x9f\x91\x87",
"point_left" = "\xf0\x9f\x91\x88",
"point_right" = "\xf0\x9f\x89\x89",
"v" = "\xe2\x9c\x8c\xef\xb8\x8f",
"raised_hands" = "\xf0\x9f\x99\x8c",
"muscle" = "\xf0\x9f\x92\xaa",
"fist" = "\xe2\x9c\x8a",
"hand" = "\xe2\x9c\x8b",
"smile" = "😄",
"laughing" = "😆",
"blush" = "😊",
"smiley" = "😃",
"wink" = "😉",
"joy" = "😂",
"rofl" = "🤣",
"relaxed" = "☺️",
"thinking" = "🤔",
"neutral_face" = "😐",
"expressionless" = "😑",
"no_mouth" = "😶",
"rolling_eyes" = "🙄",
"smirk" = "😏",
"persevere" = "😣",
"disappointed_relieved" = "😥",
"open_mouth" = "😮",
"zipper_mouth_face" = "🤐",
"hushed" = "😯",
"sleepy" = "😪",
"tired_face" = "😫",
"sleeping" = "😴",
"relieved" = "😌",
"stuck_out_tongue" = "😛",
"stuck_out_tongue_winking_eye" = "😜",
"stuck_out_tongue_closed_eyes" = "😝",
"drooling_face" = "🤤",
"unamused" = "😒",
"sweat" = "😅",
"pensive" = "😔",
"confused" = "😕",
"upside_down_face" = "🙃",
"money_mouth_face" = "🤑",
"astonished" = "😲",
"white_frowning_face" = "☹️",
"slightly_sad_face" = "☹️",
"confounded" = "😖",
"disappointed" = "😞",
"worried" = "😟",
"triumph" = "😤",
"cry" = "😢",
"sob" = "😭",
"frowning" = "😦",
"frowning_face" = "🙁",
"anguished" = "😧",
"fearful" = "😨",
"weary" = "😩",
"grimacing" = "😬",
"cold_sweat" = "😰",
"scream" = "😱",
"flushed" = "😳",
"dizzy_face" = "😵",
"rage" = "😡",
"angry" = "😠",
"innocent" = "😇",
"cowboy_hat_face" = "🤠",
"clown_face" = "🤡",
"mask" = "😷",
"thermometer_face" = "🤒",
"head_bandage" = "🤕",
"nauseated_face" = "🤢",
"sneezing_face" = "🤧",
"smiling_imp" = "😈",
"imp" = "👿",
"shrug" = "¯\\_(ツ)_/¯",
"facepalm" = "🤦",
"facepunch" = "👊",
"wave" = "👋",
"ok_hand" = "👌",
"thumbsup" = "👍",
"thumbsdown" = "👎",
"clap" = "👏",
"pray" = "🙏",
"point_up" = "☝️",
"point_down" = "👇",
"point_left" = "👈",
"point_right" = "👉",
"v" = "✌️",
"raised_hands" = "🙌",
"muscle" = "💪",
"fist" = "✊",
"hand" = "✋",
// Hearts & symbols
"heart" = "\xe2\x9d\xa4\xef\xb8\x8f",
"orange_heart" = "\xf0\x9f\xa7\xa1",
"yellow_heart" = "\xf0\x9f\x92\x9b",
"green_heart" = "\xf0\x9f\x92\x9a",
"blue_heart" = "\xf0\x9f\x92\x99",
"purple_heart" = "\xf0\x9f\x92\x9c",
"broken_heart" = "\xf0\x9f\x92\x94",
"sparkling_heart" = "\xf0\x9f\x92\x96",
"100" = "\xf0\x9f\x92\xaf",
"anger" = "\xf0\x9f\x92\xa2",
"checkered_flag" = "\xf0\x9f\x8f\x81",
"crossed_flags" = "\xf0\x9f\x9a\xa9",
"rocket" = "\xf0\x9f\x9a\x80",
"star" = "\xe2\xad\x90",
"star2" = "\xf0\x9f\x8c\x9f",
"sparkles" = "\xe2\x9c\xa8",
"boom" = "\xf0\x9f\x92\xa5",
"exclamation" = "\xe2\x9d\x97",
"question" = "\xe2\x9d\x93",
"grey_exclamation" = "\xe2\x9d\x95",
"grey_question" = "\xe2\x9d\x94",
"zzz" = "\xf0\x9f\x92\xa4",
"warning" = "\xe2\x9a\xa0\xef\xb8\x8f",
"no_entry_sign" = "\xf0\x9f\x9a\xab",
"no_entry" = "\xe2\x9b\x94",
"white_check_mark" = "\xe2\x9c\x85",
"negative_squared_cross_mark" = "\xe2\x9d\x8e",
"x" = "\xe2\x9d\x8c",
"o" = "\xe2\xad\x95",
"heavy_plus_sign" = "\xe2\x9e\x95",
"heavy_minus_sign" = "\xe2\x9e\x96",
"heavy_division_sign" = "\xe2\x9e\x97",
"copyright" = "\xc2\xa9\xef\xb8\x8f",
"registered" = "\xc2\xae\xef\xb8\x8f",
"tm" = "\xe2\x84\xa2\xef\xb8\x8f",
"heart" = "❤️",
"orange_heart" = "🧡",
"yellow_heart" = "💛",
"green_heart" = "💚",
"blue_heart" = "💙",
"purple_heart" = "💜",
"broken_heart" = "💔",
"sparkling_heart" = "💖",
"100" = "💯",
"anger" = "💢",
"checkered_flag" = "🏁",
"crossed_flags" = "🚩",
"rocket" = "🚀",
"star" = "⭐",
"star2" = "🌟",
"sparkles" = "✨",
"boom" = "💥",
"exclamation" = "❗",
"question" = "❓",
"grey_exclamation" = "❕",
"grey_question" = "❔",
"zzz" = "💤",
"warning" = "⚠️",
"no_entry_sign" = "🚫",
"no_entry" = "⛔",
"white_check_mark" = "✅",
"negative_squared_cross_mark" = "",
"x" = "❌",
"o" = "⭕",
"heavy_plus_sign" = "",
"heavy_minus_sign" = "",
"heavy_division_sign" = "➗",
"copyright" = "©️",
"registered" = "®️",
"tm" = "™️",
// Nature & weather
"fire" = "\xf0\x9f\x94\xa5",
"zap" = "\xe2\x9a\xa1",
"sunny" = "\xe2\x98\x80\xef\xb8\x8f",
"cloud" = "\xe2\x98\x81\xef\xb8\x8f",
"rainbow" = "\xf0\x9f\x8c\x88",
"snowflake" = "\xe2\x9d\x84\xef\xb8\x8f",
"dash" = "\xf0\x9f\x92\xa8",
"tornado" = "\xf0\x9f\x8c\xaa\xef\xb8\x8f",
"deciduous_tree" = "\xf0\x9f\x8c\xb3",
"evergreen_tree" = "\xf0\x9f\x8c\xb2",
"palm_tree" = "\xf0\x9f\x8c\xb4",
"cactus" = "\xf0\x9f\x8c\xb5",
"tulip" = "\xf0\x9f\x8c\xb7",
"rose" = "\xf0\x9f\x8c\xb9",
"sunflower" = "\xf0\x9f\x8c\xbb",
"hibiscus" = "\xf0\x9f\x8c\xba",
"earth_africa" = "\xf0\x9f\x8c\x8d",
"earth_americas" = "\xf0\x9f\x8c\x8e",
"earth_asia" = "\xf0\x9f\x8c\x8f",
"new_moon" = "\xf0\x9f\x8c\x9a",
"full_moon" = "\xf0\x9f\x8c\x95",
"fire" = "🔥",
"zap" = "⚡",
"sunny" = "☀️",
"cloud" = "☁️",
"rainbow" = "🌈",
"snowflake" = "❄️",
"dash" = "💨",
"tornado" = "🌪️",
"deciduous_tree" = "🌳",
"evergreen_tree" = "🌲",
"palm_tree" = "🌴",
"cactus" = "🌵",
"tulip" = "🌷",
"rose" = "🌹",
"sunflower" = "🌻",
"hibiscus" = "🌺",
"earth_africa" = "🌍",
"earth_americas" = "🌎",
"earth_asia" = "🌏",
"new_moon" = "🌑",
"full_moon" = "🌕",
// Food & drink
"coffee" = "\xe2\x98\x95",
"tea" = "\xf0\x9f\x8d\xb5",
"beer" = "\xf0\x9f\x8d\xba",
"beers" = "\xf0\x9f\x8d\xbb",
"wine_glass" = "\xf0\x9f\x8d\xb7",
"tropical_drink" = "\xf0\x9f\x8d\xb9",
"apple" = "\xf0\x9f\x8d\x8e",
"green_apple" = "\xf0\x9f\x8d\x8f",
"orange" = "\xf0\x9f\x8d\x8a",
"lemon" = "\xf0\x9f\x8d\x8b",
"banana" = "\xf0\x9f\x8d\x8c",
"watermelon" = "\xf0\x9f\x8d\x89",
"grapes" = "\xf0\x9f\x8d\x87",
"strawberry" = "\xf0\x9f\x8d\x93",
"melon" = "\xf0\x9f\x8d\x88",
"cherries" = "\xf0\x9f\x8d\x92",
"peach" = "\xf0\x9f\x8d\x91",
"pineapple" = "\xf0\x9f\x8d\x8d",
"pizza" = "\xf0\x9f\x8d\x95",
"hamburger" = "\xf0\x9f\x8d\x94",
"hotdog" = "\xf0\x9f\x8c\xad",
"taco" = "\xf0\x9f\x8c\xae",
"burrito" = "\xf0\x9f\x8c\xaf",
"cake" = "\xf0\x9f\x8d\xb0",
"cookie" = "\xf0\x9f\x8d\xaa",
"chocolate_bar" = "\xf0\x9f\x8d\xab",
"candy" = "\xf0\x9f\x8d\xac",
"popcorn" = "\xf0\x9f\x8d\xbf",
"coffee" = "☕",
"tea" = "🍵",
"beer" = "🍺",
"beers" = "🍻",
"wine_glass" = "🍷",
"tropical_drink" = "🍹",
"apple" = "🍎",
"green_apple" = "🍏",
"orange" = "🍊",
"lemon" = "🍋",
"banana" = "🍌",
"watermelon" = "🍉",
"grapes" = "🍇",
"strawberry" = "🍓",
"melon" = "🍈",
"cherries" = "🍒",
"peach" = "🍑",
"pineapple" = "🍍",
"pizza" = "🍕",
"hamburger" = "🍔",
"hotdog" = "🌭",
"taco" = "🌮",
"burrito" = "🌯",
"cake" = "🍰",
"cookie" = "🍪",
"chocolate_bar" = "🍫",
"candy" = "🍬",
"popcorn" = "🍿",
// Animals
"dog" = "\xf0\x9f\x90\xb6",
"cat" = "\xf0\x9f\x90\xb1",
"mouse" = "\xf0\x9f\x90\xad",
"hamster" = "\xf0\x9f\x90\xb9",
"rabbit" = "\xf0\x9f\x90\xb0",
"bear" = "\xf0\x9f\x90\xbb",
"panda_face" = "\xf0\x9f\x90\xbc",
"koala" = "\xf0\x9f\x90\xa8",
"tiger" = "\xf0\x9f\x90\xaf",
"lion_face" = "\xf0\x9f\xa6\x81",
"cow" = "\xf0\x9f\x90\xae",
"pig" = "\xf0\x9f\x90\xb7",
"frog" = "\xf0\x9f\x90\xb8",
"monkey_face" = "\xf0\x9f\x90\xb5",
"see_no_evil" = "\xf0\x9f\x99\x88",
"hear_no_evil" = "\xf0\x9f\x99\x89",
"speak_no_evil" = "\xf0\x9f\x99\x8a",
"owl" = "\xf0\x9f\xa6\x89",
"bat" = "\xf0\x9f\xa6\x87",
"wolf" = "\xf0\x9f\x90\xba",
"boar" = "\xf0\x9f\x90\x97",
"horse" = "\xf0\x9f\x90\x8e",
"unicorn" = "\xf0\x9f\xa6\x84",
"honeybee" = "\xf0\x9f\x90\x9d",
"bug" = "\xf0\x9f\x90\x9b",
"snail" = "\xf0\x9f\x90\x8c",
"beetle" = "\xf0\x9f\x90\x9e",
"ant" = "\xf0\x9f\x90\x9c",
"spider" = "\xf0\x9f\x95\xb7",
"scorpion" = "\xf0\x9f\xa6\x82",
"turtle" = "\xf0\x9f\x90\xa2",
"snake" = "\xf0\x9f\x90\x8d",
"octopus" = "\xf0\x9f\x90\x99",
"shell" = "\xf0\x9f\x90\x9a",
"whale" = "\xf0\x9f\x90\xb3",
"dolphin" = "\xf0\x9f\x90\xac",
"fish" = "\xf0\x9f\x90\x9f",
"tropical_fish" = "\xf0\x9f\x90\xa0",
"blowfish" = "\xf0\x9f\x90\xa1",
"penguin" = "\xf0\x9f\x90\xa7",
"chicken" = "\xf0\x9f\x90\x94",
"bird" = "\xf0\x9f\x90\xa6",
"eagle" = "\xf0\x9f\xa6\x85",
"dog" = "🐶",
"cat" = "🐱",
"mouse" = "🐭",
"hamster" = "🐹",
"rabbit" = "🐰",
"bear" = "🐻",
"panda_face" = "🐼",
"koala" = "🐨",
"tiger" = "🐯",
"lion_face" = "🦁",
"cow" = "🐮",
"pig" = "🐷",
"frog" = "🐸",
"monkey_face" = "🐵",
"see_no_evil" = "🙈",
"hear_no_evil" = "🙉",
"speak_no_evil" = "🙊",
"owl" = "🦉",
"bat" = "🦇",
"wolf" = "🐺",
"boar" = "🐗",
"horse" = "🐴",
"unicorn" = "🦄",
"honeybee" = "🐝",
"bug" = "🐛",
"snail" = "🐌",
"beetle" = "🐞",
"ant" = "🐜",
"spider" = "🕷",
"scorpion" = "🦂",
"turtle" = "🐢",
"snake" = "🐍",
"octopus" = "🐙",
"shell" = "🐚",
"whale" = "🐳",
"dolphin" = "🐬",
"fish" = "🐟",
"tropical_fish" = "🐠",
"blowfish" = "🐡",
"penguin" = "🐧",
"chicken" = "🐔",
"bird" = "🐦",
"eagle" = "🦅",
// Objects
"computer" = "\xf0\x9f\x92\xbb",
"iphone" = "\xf0\x9f\x93\xb1",
"keyboard" = "\xe2\x8c\xa8\xef\xb8\x8f",
"desktop_computer" = "\xf0\x9f\x96\xa5",
"printer" = "\xf0\x9f\x96\xa8",
"mouse_three_button" = "\xf0\x9f\x96\xb1",
"joystick" = "\xf0\x9f\x95\xb9",
"stash" = "\xf0\x9f\x92\xbe",
"floppy_disk" = "\xf0\x9f\x92\xbe",
"cd" = "\xf0\x9f\x92\xbf",
"dvd" = "\xf0\x9f\x93\x80",
"vhs" = "\xf0\x9f\x93\xbc",
"camera" = "\xf0\x9f\x93\xb7",
"video_camera" = "\xf0\x9f\x93\xb9",
"tv" = "\xf0\x9f\x93\xba",
"radio" = "\xf0\x9f\x93\xbb",
"pager" = "\xf0\x9f\x93\x9f",
"telephone" = "\xe2\x98\x8e\xef\xb8\x8f",
"fax" = "\xf0\x9f\x93\xa0",
"bulb" = "\xf0\x9f\x92\xa1",
"candle" = "\xf0\x9f\x95\xaf",
"bathtub" = "\xf0\x9f\x9b\x81",
"shower" = "\xf0\x9f\x9a\xbf",
"toilet" = "\xf0\x9f\x9a\xbd",
"wrench" = "\xf0\x9f\x94\xa7",
"hammer" = "\xf0\x9f\x94\xa8",
"nut_and_bolt" = "\xf0\x9f\x94\xa9",
"gear" = "\xe2\x9a\x99\xef\xb8\x8f",
"link" = "\xf0\x9f\x94\x97",
"lock" = "\xf0\x9f\x94\x92",
"unlock" = "\xf0\x9f\x94\x93",
"key" = "\xf0\x9f\x94\x91",
"bell" = "\xf0\x9f\x94\x94",
"bookmark" = "\xf0\x9f\x94\x96",
"pushpin" = "\xf0\x9f\x93\x8c",
"paperclip" = "\xf0\x9f\x93\x8e",
"memo" = "\xf0\x9f\x93\x9d",
"pencil2" = "\xe2\x9c\x8f\xef\xb8\x8f",
"black_nib" = "\xe2\x9c\x92\xef\xb8\x8f",
"pen" = "\xf0\x9f\x96\x8a",
"paintbrush" = "\xf0\x9f\x96\x8c",
"crayon" = "\xf0\x9f\x96\x8d",
"book" = "\xf0\x9f\x93\x97",
"books" = "\xf0\x9f\x93\x9a",
"ledger" = "\xf0\x9f\x93\x92",
"notebook" = "\xf0\x9f\x93\x93",
"scroll" = "\xf0\x9f\x93\x9c",
"page_with_curl" = "\xf0\x9f\x93\x84",
"newspaper" = "\xf0\x9f\x93\xb0",
"chart_with_upwards_trend" = "\xf0\x9f\x93\x88",
"chart_with_downwards_trend" = "\xf0\x9f\x93\x89",
"bar_chart" = "\xf0\x9f\x93\x8a",
"calendar" = "\xf0\x9f\x93\x85",
"date" = "\xf0\x9f\x93\x85",
"hourglass" = "\xe2\x8f\xb3",
"hourglass_flowing_sand" = "\xe2\x8f\xb3",
"clock" = "\xf0\x9f\x95\x90",
"alarm_clock" = "\xe2\x8f\xb0",
"stopwatch" = "\xe2\x8f\xb1",
"watch" = "\xe2\x8c\x9a",
"moneybag" = "\xf0\x9f\x92\xb0",
"yen" = "\xf0\x9f\x92\xb4",
"dollar" = "\xf0\x9f\x92\xb5",
"euro" = "\xf0\x9f\x92\xb6",
"pound" = "\xf0\x9f\x92\xb7",
"money_with_wings" = "\xf0\x9f\x92\xb8",
"credit_card" = "\xf0\x9f\x92\xb3",
"gem" = "\xf0\x9f\x92\x8e",
"bomb" = "\xf0\x9f\x92\xa3",
"gift" = "\xf0\x9f\x8e\x81",
"balloon" = "\xf0\x9f\x8e\x88",
"tada" = "\xf0\x9f\x8e\x89",
"confetti_ball" = "\xf0\x9f\x8e\x8a",
"package" = "\xf0\x9f\x93\xa6",
"mailbox" = "\xf0\x9f\x93\xab",
"inbox_tray" = "\xf0\x9f\x93\xa5",
"outbox_tray" = "\xf0\x9f\x93\xa4",
"email" = "\xe2\x9c\x89\xef\xb8\x8f",
"envelope" = "\xe2\x9c\x89\xef\xb8\x8f",
"incoming_envelope" = "\xf0\x9f\x93\xa8",
"computer" = "💻",
"iphone" = "📱",
"keyboard" = "⌨️",
"desktop_computer" = "🖥",
"printer" = "🖨",
"mouse_three_button" = "🖱",
"joystick" = "🕹",
"stash" = "💾",
"floppy_disk" = "💾",
"cd" = "💿",
"dvd" = "📀",
"vhs" = "📼",
"camera" = "📷",
"video_camera" = "📹",
"tv" = "📺",
"radio" = "📻",
"pager" = "📟",
"telephone" = "☎️",
"fax" = "📠",
"bulb" = "💡",
"candle" = "🕯",
"bathtub" = "🛁",
"shower" = "🚿",
"toilet" = "🚽",
"wrench" = "🔧",
"hammer" = "🔨",
"nut_and_bolt" = "🔩",
"gear" = "⚙️",
"link" = "🔗",
"lock" = "🔒",
"unlock" = "🔓",
"key" = "🔑",
"bell" = "🔔",
"bookmark" = "🔖",
"pushpin" = "📌",
"paperclip" = "📎",
"memo" = "📝",
"pencil2" = "✏️",
"black_nib" = "✒️",
"pen" = "🖊",
"paintbrush" = "🖌",
"crayon" = "🖍",
"book" = "📖",
"books" = "📚",
"ledger" = "📒",
"notebook" = "📓",
"scroll" = "📜",
"page_with_curl" = "📄",
"newspaper" = "📰",
"chart_with_upwards_trend" = "📈",
"chart_with_downwards_trend" = "📉",
"bar_chart" = "📊",
"calendar" = "📅",
"date" = "📅",
"hourglass" = "⌛",
"hourglass_flowing_sand" = "",
"clock" = "🕐",
"alarm_clock" = "⏰",
"stopwatch" = "⏱",
"watch" = "⌚",
"moneybag" = "💰",
"yen" = "💴",
"dollar" = "💵",
"euro" = "💶",
"pound" = "💷",
"money_with_wings" = "💸",
"credit_card" = "💳",
"gem" = "💎",
"bomb" = "💣",
"gift" = "🎁",
"balloon" = "🎈",
"tada" = "🎉",
"confetti_ball" = "🎊",
"package" = "📦",
"mailbox" = "📫",
"inbox_tray" = "📥",
"outbox_tray" = "📤",
"email" = "✉️",
"envelope" = "✉️",
"incoming_envelope" = "📨",
// Activities
"soccer" = "\xe2\x9a\xbd",
"basketball" = "\xf0\x9f\x8f\x80",
"football" = "\xf0\x9f\x8f\x88",
"baseball" = "\xe2\x9a\xbe",
"tennis" = "\xf0\x9f\x8e\xbe",
"8ball" = "\xf0\x9f\x8e\xb1",
"bowling" = "\xf0\x9f\x8e\xb3",
"video_game" = "\xf0\x9f\x8e\xae",
"dart" = "\xf0\x9f\x8e\xaf",
"game_die" = "\xf0\x9f\x8e\xb2",
"slot_machine" = "\xf0\x9f\x8e\xb0",
"cards" = "\xf0\x9f\x83\x8f",
"black_joker" = "\xf0\x9f\x83\x9f",
"mahjong" = "\xf0\x9f\x80\x84",
"musical_note" = "\xf0\x9f\x8e\xb5",
"notes" = "\xf0\x9f\x8e\xb6",
"saxophone" = "\xf0\x9f\x8e\xb7",
"guitar" = "\xf0\x9f\x8e\xb8",
"musical_keyboard" = "\xf0\x9f\x8e\xb9",
"trumpet" = "\xf0\x9f\x8e\xba",
"violin" = "\xf0\x9f\x8e\xbb",
"drum" = "\xf0\x9f\xa5\x81",
"headphones" = "\xf0\x9f\x8e\xa7",
"microphone" = "\xf0\x9f\x8e\xa4",
"level_slider" = "\xf0\x9f\x8e\x9a",
"control_knobs" = "\xf0\x9f\x8e\x9b",
"ticket" = "\xf0\x9f\x8e\xab",
"art" = "\xf0\x9f\x8e\xa8",
"circus_tent" = "\xf0\x9f\x8e\xaa",
"theater_masks" = "\xf0\x9f\x8e\xad",
"clapper" = "\xf0\x9f\x8e\xac",
"soccer" = "⚽",
"basketball" = "🏀",
"football" = "🏈",
"baseball" = "⚾",
"tennis" = "🎾",
"8ball" = "🎱",
"bowling" = "🎳",
"video_game" = "🎮",
"dart" = "🎯",
"game_die" = "🎲",
"slot_machine" = "🎰",
"cards" = "🃏",
"black_joker" = "🃟",
"mahjong" = "🀄",
"musical_note" = "🎵",
"notes" = "🎶",
"saxophone" = "🎷",
"guitar" = "🎸",
"musical_keyboard" = "🎹",
"trumpet" = "🎺",
"violin" = "🎻",
"drum" = "🥁",
"headphones" = "🎧",
"microphone" = "🎤",
"level_slider" = "🎚",
"control_knobs" = "🎛",
"ticket" = "🎫",
"art" = "🎨",
"circus_tent" = "🎪",
"theater_masks" = "🎭",
"clapper" = "🎬",
// Travel
"car" = "\xf0\x9f\x9a\x97",
"taxi" = "\xf0\x9f\x9a\x95",
"bus" = "\xf0\x9f\x9a\x8d",
"train" = "\xf0\x9f\x9a\x86",
"metro" = "\xf0\x9f\x9a\x87",
"light_rail" = "\xf0\x9f\x9a\x88",
"tram" = "\xf0\x9f\x9a\x8a",
"bike" = "\xf0\x9f\x9a\xb2",
"motorcycle" = "\xf0\x9f\x8f\x8d",
"airplane" = "\xe2\x9c\x88\xef\xb8\x8f",
"helicopter" = "\xf0\x9f\x9a\x81",
"boat" = "\xe2\x9b\xb5",
"sailboat" = "\xe2\x9b\xb5",
"ship" = "\xf0\x9f\x9a\xa2",
"fuelpump" = "\xe2\x9b\xbd",
"construction" = "\xf0\x9f\x9a\xa7",
"house" = "\xf0\x9f\x8f\xa0",
"house_with_garden" = "\xf0\x9f\x8f\xa1",
"office" = "\xf0\x9f\x8f\xa2",
"post_office" = "\xf0\x9f\x8f\xa3",
"hospital" = "\xf0\x9f\x8f\xa5",
"bank" = "\xf0\x9f\x8f\xa6",
"hotel" = "\xf0\x9f\x8f\xa8",
"school" = "\xf0\x9f\x8f\xab",
"department_store" = "\xf0\x9f\x8f\xac",
"church" = "\xe2\x9b\xaa",
"castle" = "\xf0\x9f\x8f\xb0",
"factory" = "\xf0\x9f\x8f\xad",
"tokyo_tower" = "\xf0\x9f\x97\xbc",
"statue_of_liberty" = "\xf0\x9f\x97\xbd",
"fountain" = "\xe2\x9b\xb2",
"tent" = "\xe2\x9b\xba",
"mountain" = "\xf0\x9f\x8f\x94",
"snow_capped_mountain" = "\xf0\x9f\x8f\x94",
"beach" = "\xf0\x9f\x8f\x96",
"camping" = "\xf0\x9f\x8f\x95",
"world_map" = "\xf0\x9f\x97\xba",
"japan" = "\xf0\x9f\x97\xbe",
};
"car" = "🚗",
"taxi" = "🚕",
"bus" = "🚍",
"train" = "🚆",
"metro" = "🚇",
"light_rail" = "🚈",
"tram" = "🚊",
"bike" = "🚲",
"motorcycle" = "🏍",
"airplane" = "✈️",
"helicopter" = "🚁",
"boat" = "⛵",
"sailboat" = "⛵",
"ship" = "🚢",
"fuelpump" = "⛽",
"construction" = "🚧",
"house" = "🏠",
"house_with_garden" = "🏡",
"office" = "🏢",
"post_office" = "🏣",
"hospital" = "🏥",
"bank" = "🏦",
"hotel" = "🏨",
"school" = "🏫",
"department_store" = "🏬",
"church" = "⛪",
"castle" = "🏰",
"factory" = "🏭",
"tokyo_tower" = "🗼",
"statue_of_liberty" = "🗽",
"fountain" = "⛲",
"tent" = "⛺",
"mountain" = "⛰️",
"snow_capped_mountain" = "🏔",
"beach" = "🏖",
"camping" = "🏕",
"world_map" = "🗺",
"japan" = "🗾",
}
expand_emoji :: proc(text: string) -> string {
if len(EMOJIS) == 0 {
return text
}
assert(len(EMOJIS) > 0)
sb := strings.builder_make()
defer strings.builder_destroy(&sb)
@@ -403,11 +401,15 @@ expand_emoji :: proc(text: string) -> string {
break
}
shortcode := remaining[colon + 1 : colon + 1 + end]
shortcode := remaining[colon + 1:colon + 1 + end]
valid := true
for c in shortcode {
if !(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '_' && c != '+' && c != '-' {
if !(c >= 'a' && c <= 'z') &&
!(c >= '0' && c <= '9') &&
c != '_' &&
c != '+' &&
c != '-' {
valid = false
break
}
@@ -432,3 +434,4 @@ expand_emoji :: proc(text: string) -> string {
return strings.to_string(sb)
}
+33
View File
@@ -0,0 +1,33 @@
#+test
package markdown
import "core:testing"
@(test)
test_basic_emoji_expansion_works :: proc(t: ^testing.T) {
testing.expect_value(t, expand_emoji(":smile:"), "😄")
testing.expect_value(t, expand_emoji(":smile: :heart:"), "😄 ❤️")
testing.expect_value(t, expand_emoji(":smile::heart:"), "😄❤️")
testing.expect_value(t, expand_emoji("Hello :wave:!"), "Hello 👋!")
testing.expect_value(t, expand_emoji(":smile: rest"), "😄 rest")
testing.expect_value(t, expand_emoji("rest :smile:"), "rest 😄")
testing.expect_value(t, expand_emoji(":100:"), "💯")
testing.expect_value(t, expand_emoji(":stuck_out_tongue:"), "😛")
}
@(test)
test_emoji_skips_non_matches :: proc(t: ^testing.T) {
testing.expect_value(t, expand_emoji("Hello world"), "Hello world")
testing.expect_value(t, expand_emoji("Hello:"), "Hello:")
testing.expect_value(t, expand_emoji(":notreal:"), ":notreal:")
testing.expect_value(t, expand_emoji("http://example.com"), "http://example.com")
testing.expect_value(t, expand_emoji(""), "")
}
@(test)
test_emoji_skips_invalid_shortcodes :: proc(t: ^testing.T) {
testing.expect_value(t, expand_emoji("::"), "::")
testing.expect_value(t, expand_emoji(":Smile:"), ":Smile:")
testing.expect_value(t, expand_emoji(": not real :"), ": not real :")
}
+4 -2
View File
@@ -37,8 +37,10 @@ wrap_sections :: proc(html: string) -> string {
found = true
}
if !found {
if found {
return strings.to_string(sb)
} else {
return html
}
return strings.to_string(sb)
}
+49
View File
@@ -0,0 +1,49 @@
#+test
package markdown
import "core:testing"
@(test)
test_wrap_sections_works :: proc(t: ^testing.T) {
testing.expect_value(
t,
wrap_sections("<p>intro</p><h2>Title</h2><p>body</p>"),
"<section><p>intro</p></section><section><h2>Title</h2><p>body</p></section>",
)
testing.expect_value(
t,
wrap_sections("<p>a</p><h2>A</h2><p>b</p><h2>B</h2><p>c</p>"),
"<section><p>a</p></section><section><h2>A</h2><p>b</p></section><section><h2>B</h2><p>c</p></section>",
)
testing.expect_value(
t,
wrap_sections("<h2>Title</h2><p>body</p>"),
"<section><h2>Title</h2><p>body</p></section>",
)
testing.expect_value(
t,
wrap_sections("<p>intro</p><h2>Title</h2>"),
"<section><p>intro</p></section><section><h2>Title</h2></section>",
)
}
@(test)
test_wrap_sections_doesnt_split_content :: proc(t: ^testing.T) {
testing.expect_value(
t,
wrap_sections("<p>just text</p>"),
"<section><p>just text</p></section>",
)
testing.expect_value(t, wrap_sections(""), "")
testing.expect_value(
t,
wrap_sections("<h1>Big</h1><h3>Small</h3>"),
"<section><h1>Big</h1><h3>Small</h3></section>",
)
}
+26
View File
@@ -155,6 +155,32 @@ is_truthy :: proc(a: any) -> bool {
}
}
call_interp_lambda :: proc(val: any) -> (result: string, ok: bool) {
switch v in val {
case proc() -> string:
return v(), true
case proc() -> int:
return fmt.tprintf("%d", v()), true
case proc() -> bool:
return "true" if v() else "false", true
case:
return "", false
}
}
call_section_lambda :: proc(val: any, text: string) -> (result: string, ok: bool) {
switch v in val {
case proc(string) -> string:
return v(text), true
case proc(string) -> int:
return fmt.tprintf("%d", v(text)), true
case proc(string) -> bool:
return "true" if v(text) else "false", true
case:
return "", false
}
}
// list_info returns element type info, count, and data pointer for a list value.
// Returns elem_info=nil if the value is not a list.
list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: rawptr) {
+147
View File
@@ -0,0 +1,147 @@
#+test
package mustache
import "core:fmt"
import "core:testing"
// --- Spec test 1: Interpolation ---
// A lambda's return value should be interpolated.
Interp_Data :: struct {
lambda: proc() -> string,
planet: string,
}
Interp_Data_Int :: struct {
lambda: proc() -> int,
planet: string,
}
@(test)
test_lambda_interpolation :: proc(t: ^testing.T) {
data := Interp_Data {
lambda = proc() -> string {return "world"},
}
tpl, _ := parse("Hello, {{lambda}}!", context.temp_allocator)
result, _ := render(tpl, data, allocator = context.temp_allocator)
testing.expect_value(t, result, "Hello, world!")
}
// --- Spec test 2: Interpolation - Expansion ---
// A lambda's return value should be parsed.
@(test)
test_lambda_interpolation_expansion :: proc(t: ^testing.T) {
data := Interp_Data {
lambda = proc() -> string {return "{{planet}}"},
planet = "world",
}
tpl, _ := parse("Hello, {{lambda}}!", context.temp_allocator)
result, _ := render(tpl, data, allocator = context.temp_allocator)
testing.expect_value(t, result, "Hello, world!")
}
// --- Spec test 4: Interpolation - Multiple Calls ---
// Interpolated lambdas should not be cached.
counter_lambda :: proc() -> int {
@(static) call_count := 0
call_count += 1
return call_count
}
@(test)
test_lambda_interpolation_multiple_calls :: proc(t: ^testing.T) {
data := Interp_Data_Int {
lambda = counter_lambda,
}
tpl, _ := parse("{{lambda}} == {{{lambda}}} == {{lambda}}", context.temp_allocator)
result, _ := render(tpl, data, allocator = context.temp_allocator)
testing.expect_value(t, result, "1 == 2 == 3")
}
// --- Spec test 5: Escaping ---
// Lambda results should be appropriately escaped.
@(test)
test_lambda_escaping :: proc(t: ^testing.T) {
data := Interp_Data {
lambda = proc() -> string {return ">"},
}
tpl, _ := parse("<{{lambda}}{{{lambda}}}", context.temp_allocator)
result, _ := render(tpl, data, allocator = context.temp_allocator)
testing.expect_value(t, result, "<&gt;>")
}
// --- Spec test 6: Section ---
// Lambdas used for sections should receive the raw section string.
Section_Data :: struct {
lambda: proc(_: string) -> string,
x: string,
planet: string,
}
@(test)
test_lambda_section :: proc(t: ^testing.T) {
data := Section_Data {
lambda = proc(text: string) -> string {
if text == "{{x}}" {return "yes"} else {return "no"}
},
x = "Error!",
}
tpl, _ := parse("<{{#lambda}}{{x}}{{/lambda}}>", context.temp_allocator)
result, _ := render(tpl, data, allocator = context.temp_allocator)
testing.expect_value(t, result, "<yes>")
}
// --- Spec test 7: Section - Expansion ---
// Lambdas used for sections should have their results parsed.
@(test)
test_lambda_section_expansion :: proc(t: ^testing.T) {
data := Section_Data {
lambda = proc(text: string) -> string {
return fmt.tprintf("%s{{{{planet}}}}%s", text, text)
},
planet = "Earth",
}
tpl, _ := parse("<{{#lambda}}-{{/lambda}}>", context.temp_allocator)
result, _ := render(tpl, data, allocator = context.temp_allocator)
testing.expect_value(t, result, "<-Earth->")
}
// --- Spec test 9: Section - Multiple Calls ---
// Lambdas used for sections should not be cached.
@(test)
test_lambda_section_multiple_calls :: proc(t: ^testing.T) {
data := Section_Data {
lambda = proc(text: string) -> string {
return fmt.tprintf("__%s__", text)
},
}
tpl, _ := parse("{{#lambda}}FILE{{/lambda}} != {{#lambda}}LINE{{/lambda}}", context.temp_allocator)
result, _ := render(tpl, data, allocator = context.temp_allocator)
testing.expect_value(t, result, "__FILE__ != __LINE__")
}
// --- Spec test 10: Inverted Section ---
// Lambdas used for inverted sections should be considered truthy.
Inverted_Data :: struct {
lambda: proc(_: string) -> bool,
static: string,
}
@(test)
test_lambda_inverted_section :: proc(t: ^testing.T) {
data := Inverted_Data {
lambda = proc(text: string) -> bool {return false},
static = "static",
}
tpl, _ := parse("<{{^lambda}}{{static}}{{/lambda}}>", context.temp_allocator)
result, _ := render(tpl, data, allocator = context.temp_allocator)
testing.expect_value(t, result, "<>")
}
+45 -7
View File
@@ -48,6 +48,7 @@ Node :: struct {
indent: string,
first_child: int,
child_count: int,
content: string,
}
// node_span returns the number of flat-array entries a node occupies:
@@ -103,7 +104,7 @@ parse :: proc(
return {}, terr
}
tmpl.nodes, err = parse_tokens(tokens[:], allocator)
tmpl.nodes, err = parse_tokens(tokens[:], source, allocator)
if err != nil {
delete(tmpl.nodes)
return {}, err
@@ -148,6 +149,7 @@ render :: proc(
parse_tokens :: proc(
tokens: []Token,
source: string,
allocator := context.allocator,
) -> (
nodes: [dynamic]Node,
@@ -155,7 +157,7 @@ parse_tokens :: proc(
) {
nodes = make([dynamic]Node, 0, len(tokens), allocator)
pos := 0
err = parse_section(tokens, &pos, &nodes, "")
err = parse_section(tokens, &pos, &nodes, "", source)
return
}
@@ -164,6 +166,7 @@ parse_section :: proc(
pos: ^int,
nodes: ^[dynamic]Node,
end_tag: string,
source: string,
) -> Render_Error {
for pos^ < len(tokens) {
tok := tokens[pos^]
@@ -187,18 +190,28 @@ parse_section :: proc(
case .Section_Open:
pos^ += 1
idx := len(nodes)
content_start := 0
if pos^ < len(tokens) { content_start = tokens[pos^].pos }
append(nodes, Node{kind = .Section, key = tok.value, first_child = -1})
parse_section(tokens, pos, nodes, tok.value) or_return
parse_section(tokens, pos, nodes, tok.value, source) or_return
close_pos := 0
if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) { close_pos = tokens[pos^ - 1].pos }
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
nodes[idx].content = source[content_start:close_pos]
case .Inverted_Open:
pos^ += 1
idx := len(nodes)
content_start := 0
if pos^ < len(tokens) { content_start = tokens[pos^].pos }
append(nodes, Node{kind = .Inverted, key = tok.value, first_child = -1})
parse_section(tokens, pos, nodes, tok.value) or_return
parse_section(tokens, pos, nodes, tok.value, source) or_return
close_pos := 0
if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) { close_pos = tokens[pos^ - 1].pos }
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
nodes[idx].content = source[content_start:close_pos]
case .Section_Close:
if end_tag != "" && tok.value == end_tag {
@@ -236,7 +249,7 @@ parse_section :: proc(
nodes,
Node{kind = .Parent, key = tok.value, indent = tok.indent, first_child = -1},
)
parse_section(tokens, pos, nodes, tok.value) or_return
parse_section(tokens, pos, nodes, tok.value, source) or_return
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
@@ -247,7 +260,7 @@ parse_section :: proc(
nodes,
Node{kind = .Block, key = tok.value, indent = tok.indent, first_child = -1},
)
parse_section(tokens, pos, nodes, tok.value) or_return
parse_section(tokens, pos, nodes, tok.value, source) or_return
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
}
@@ -447,17 +460,42 @@ render_nodes :: proc(
case .Variable:
val := resolve_name(node.key, ctx[:])
if result_str, ok := call_interp_lambda(val); ok {
sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator)
if perr == nil {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(sub_tpl.nodes[:], sub_tpl.nodes[:], ctx, partials, &temp, blocks) or_return
write_value(b, strings.to_string(temp), escape = true)
}
} else {
write_value(b, val, escape = true)
}
i += 1
case .Unescaped:
val := resolve_name(node.key, ctx[:])
if result_str, ok := call_interp_lambda(val); ok {
sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator)
if perr == nil {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(sub_tpl.nodes[:], sub_tpl.nodes[:], ctx, partials, &temp, blocks) or_return
write_value(b, strings.to_string(temp), escape = false)
}
} else {
write_value(b, val, escape = false)
}
i += 1
case .Section:
val := resolve_name(node.key, ctx[:])
if is_truthy(val) {
if result_str, ok := call_section_lambda(val, node.content); ok {
sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator)
if perr == nil {
render_nodes(sub_tpl.nodes[:], sub_tpl.nodes[:], ctx, partials, b, blocks) or_return
}
} else if is_truthy(val) {
children := all_nodes[node.first_child:node.first_child + node.child_count]
elem_info, count, data := list_info(val)
if elem_info != nil {
+40
View File
@@ -0,0 +1,40 @@
package main
import "core:fmt"
Open_Graph :: struct {
title: string,
type: string,
image: string,
url: string,
description: string,
locale: string,
site_name: string,
is_article: bool,
published_time: string,
modified_time: string,
section: string,
}
og_init :: proc(site: Site) -> Open_Graph {
return {
site_name = site.title,
description = site.description,
image = fmt.tprintf("%s/avatar.jpg", site.base_url),
locale = "en_US",
}
}
og_for_page :: proc(site: Site, page: Page, base: Open_Graph) -> Open_Graph {
og := base
is_article := page.section != ""
og.url = page.url
og.title = strip_html_tags(page.title, context.temp_allocator)
og.type = "article" if is_article else "website"
og.is_article = is_article
og.section = page.section
og.published_time = page.date
return og
}
+31 -55
View File
@@ -29,13 +29,7 @@ Base_Data :: struct {
params: json.Value,
body: string,
title: string,
og_site_name: string,
og_description: string,
og_image: string,
og_url: string,
og_title: string,
og_type: string,
is_article: bool,
og: Open_Graph,
}
Page_Data :: struct {
@@ -43,20 +37,17 @@ Page_Data :: struct {
page_title: string,
date_iso: string,
date_display: string,
is_post: bool,
og_section: string,
og_published: string,
}
Home_Data :: struct {
using base: Base_Data,
list_pages: [dynamic]Page_Context,
pages: [dynamic]Page_Context,
}
Section_Data :: struct {
using base: Base_Data,
page_title: string,
year_sections: [dynamic]Year_Section,
by_year: [dynamic]Year_Section,
}
build_page_context :: proc(page: Page) -> Page_Context {
@@ -69,8 +60,8 @@ build_page_context :: proc(page: Page) -> Page_Context {
}
}
strip_html_tags :: proc(s: string) -> string {
sb := strings.builder_make()
strip_html_tags :: proc(s: string, allocator := context.allocator) -> string {
sb := strings.builder_make(allocator)
defer strings.builder_destroy(&sb)
in_tag := false
@@ -95,22 +86,15 @@ strip_html_tags :: proc(s: string) -> string {
return strings.to_string(sb)
}
og_type :: proc(is_article: bool) -> string {
if is_article {
return "article"
}
return "website"
}
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)
log.warnf("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", virtual_path, err)
log.warnf("failed to parse template %s: %v", virtual_path, err)
}
return tpl
}
@@ -133,7 +117,7 @@ get_template :: proc(
chain[n] = "base"; n += 1
}
for i in 0..<n {
for i in 0 ..< n {
candidate := chain[i]
if cached, ok := cache[candidate]; ok {
return cached
@@ -145,11 +129,11 @@ get_template :: proc(
return tpl
}
if candidate != chain[n - 1] {
log.warnf("thor: template %s not found, falling back", virtual)
log.debugf("template %s not found, falling back", virtual)
}
}
log.errorf("thor: base.html not found in VFS")
log.errorf("base.html not found in VFS")
return mustache.Template{}
}
@@ -170,7 +154,7 @@ render_template :: proc(
) -> string {
result, err := mustache.render(content_tpl, data, partials)
if err != nil {
fmt.eprintfln("thor: mustache error: %v", err)
fmt.eprintfln("mustache error: %v", err)
return ""
}
return result
@@ -195,9 +179,7 @@ render_site :: proc(site: ^Site) {
now = now,
author = site.author,
params = site.params,
og_site_name = site.title,
og_description = site.description,
og_image = fmt.tprintf("%s/avatar.jpg", site.base_url),
og = og_init(site^),
}
// Find home page
@@ -310,13 +292,7 @@ render_page_html :: proc(
data.body = page.body_html
data.date_iso = page.date
data.date_display = format_date(page.date)
data.is_post = is_article
data.og_url = fmt.tprintf("%s%s", site.base_url, page.permalink)
data.og_title = strip_html_tags(page.title)
data.og_type = og_type(is_article)
data.is_article = is_article
data.og_section = page.section
data.og_published = page.date
data.og = og_for_page(site^, page, base.og)
return render_template(content_tpl, data, partials)
}
@@ -341,11 +317,11 @@ render_home_html :: proc(
}
data.title = site.title
data.body = home.body_html
data.list_pages = list_pages
data.og_url = fmt.tprintf("%s/", site.base_url)
data.og_title = site.title
data.og_type = "website"
data.is_article = false
data.pages = list_pages
data.og.url = fmt.tprintf("%s/", site.base_url)
data.og.title = site.title
data.og.type = "website"
data.og.is_article = false
return render_template(content_tpl, data, partials)
}
@@ -358,8 +334,8 @@ render_section :: proc(
partials: map[string]mustache.Template,
base: Base_Data,
) -> string {
year_sections := make([dynamic]Year_Section)
defer delete(year_sections)
by_year := make([dynamic]Year_Section)
defer delete(by_year)
current_year := ""
for page in site.pages {
if page.section != section || page._is_index {
@@ -367,10 +343,10 @@ render_section :: proc(
}
year := get_year(page.date)
if year != current_year {
append(&year_sections, Year_Section{year = year})
append(&by_year, Year_Section{year = year})
current_year = year
}
append(&year_sections[len(year_sections) - 1].posts, build_page_context(page))
append(&by_year[len(by_year) - 1].posts, build_page_context(page))
}
data := Section_Data {
@@ -380,16 +356,16 @@ render_section :: proc(
data.body = section_index.body_html
data.page_title = section_index.title
data.title = fmt.tprintf("%s | %s", section_index.title, site.title)
data.og_title = section_index.title
data.og.title = section_index.title
} else {
data.page_title = capitalize(section)
data.title = fmt.tprintf("%s | %s", capitalize(section), site.title)
data.og_title = capitalize(section)
data.og.title = capitalize(section)
}
data.year_sections = year_sections
data.og_url = fmt.tprintf("%s/%s/", site.base_url, section)
data.og_type = "website"
data.is_article = false
data.by_year = by_year
data.og.url = fmt.tprintf("%s/%s/", site.base_url, section)
data.og.type = "website"
data.og.is_article = false
return render_template(content_tpl, data, partials)
}
@@ -413,7 +389,7 @@ load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template {
}
tpl, err := mustache.parse(string(data))
if err != nil {
log.warnf("thor: failed to parse partial %s: %v", key, err)
log.warnf("failed to parse partial %s: %v", key, err)
continue
}
partials[key] = tpl
@@ -456,7 +432,7 @@ write_page :: proc(output_dir: string, permalink: string, html: string) {
dir := fmt.tprintf("%s/%s", output_dir, rel)
if err := os.make_directory_all(dir); err != nil && err != .Exist {
fmt.eprintfln("thor: cannot create %s: %v", dir, err)
fmt.eprintfln("cannot create %s: %v", dir, err)
return
}
@@ -466,7 +442,7 @@ write_page :: proc(output_dir: string, permalink: string, html: string) {
write_file :: proc(path: string, html: string) {
if err := os.write_entire_file_from_string(path, html); err != nil {
fmt.eprintfln("thor: cannot write %s: %v", path, err)
fmt.eprintfln("cannot write %s: %v", path, err)
}
}
+2 -2
View File
@@ -85,7 +85,7 @@ init_site :: proc(site: ^Site, args: []string) {
found, ok := find_config("thor.json")
if ok {
path = found
log.debugf("thor: using config %s", path)
log.debugf("using config %s", path)
} else {
path = "./thor.json"
}
@@ -121,7 +121,7 @@ load_config_file :: proc(
unmarshal_err := json.unmarshal_string(string(data), config, allocator = allocator)
if unmarshal_err != nil {
log.warnf("thor: failed to parse %s: %v", path, unmarshal_err)
log.warnf("failed to parse %s: %v", path, unmarshal_err)
return false
}