mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
feat: Added 'Table of Contents' markdown extension.
This commit is contained in:
@@ -110,6 +110,12 @@
|
||||
- [ ] Add opt-in deflist support.
|
||||
- [x] Decide if lambdas actually provide any value.
|
||||
- [ ] add tables extension
|
||||
- [x] Table of contents support.
|
||||
- [ ] enable template level rendering of TOCs
|
||||
- [ ] Write css for toc sidebar and figure out where to put it.
|
||||
- [ ] Add [hugo style configuration](https://gohugo.io/configuration/markup/#table-of-contents)
|
||||
- [ ] Link checker?
|
||||
- Checks all links on each page to make sure they are valid.
|
||||
- [ ] Peruse [GitHub's](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
|
||||
docs for any juicy nuggets we may have missed.
|
||||
|
||||
@@ -194,7 +200,6 @@ main :: proc () {
|
||||
- [ ] 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.
|
||||
- [ ] 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
|
||||
|
||||
@@ -28,6 +28,7 @@ Page :: struct {
|
||||
content: string,
|
||||
og: Open_Graph,
|
||||
draft: bool,
|
||||
toc: string,
|
||||
_is_index: bool `private`,
|
||||
}
|
||||
|
||||
@@ -280,6 +281,10 @@ load_page :: proc(
|
||||
page.content = md.process(body, ext, file_path, context.allocator)
|
||||
}
|
||||
|
||||
if fm.toc {
|
||||
page.toc = md.generate_toc(page.content, context.allocator)
|
||||
}
|
||||
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
<main>
|
||||
<article>
|
||||
<h1>{{page.title}}</h1>
|
||||
{{#date}} <time class="subtitle" datetime="{{date}}">{{ date | format}}</time>
|
||||
{{/date}} {{&content}}
|
||||
{{#date}}<time class="subtitle" datetime="{{date}}">{{ date | format}}</time>{{/date}}
|
||||
{{#page.toc}}
|
||||
<nav class="toc">
|
||||
{{&page.toc}}
|
||||
</nav>
|
||||
{{/page.toc}}
|
||||
{{&content}}
|
||||
</article>
|
||||
</main>
|
||||
{{/main}}
|
||||
|
||||
@@ -16,6 +16,7 @@ Frontmatter :: struct {
|
||||
layout: string,
|
||||
og: Open_Graph,
|
||||
draft: bool,
|
||||
toc: bool,
|
||||
}
|
||||
|
||||
// parse_frontmatter splits raw file content into a Frontmatter struct and the
|
||||
@@ -56,6 +57,7 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
|
||||
fm.publishDate = json_get_string(obj, "publishDate")
|
||||
fm.weight = json_get_int(obj, "weight")
|
||||
fm.draft = json_get_bool(obj, "draft")
|
||||
fm.toc = json_get_bool(obj, "toc")
|
||||
if v, ok := obj["menus"]; ok {
|
||||
fm.menus = v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package markdown
|
||||
|
||||
import "core:strings"
|
||||
|
||||
// generate_toc scans rendered HTML for <h1>-<h6> tags with id attributes and
|
||||
// builds a nested <ul> table of contents. Returns "" if no headings with IDs
|
||||
// are found. Must be called after inject_heading_ids.
|
||||
generate_toc :: proc(html: string, allocator := context.allocator) -> string {
|
||||
b: strings.Builder
|
||||
strings.builder_init(&b, allocator)
|
||||
|
||||
current_level := 0
|
||||
min_level := 7
|
||||
pos := 0
|
||||
|
||||
for {
|
||||
idx, level, id, text, next_pos := next_heading(html, pos)
|
||||
if level == 0 {
|
||||
break
|
||||
}
|
||||
pos = next_pos
|
||||
|
||||
if level < min_level {
|
||||
min_level = level
|
||||
}
|
||||
|
||||
if current_level == 0 {
|
||||
current_level = level
|
||||
strings.write_string(&b, "<ul>\n")
|
||||
} else if level > current_level {
|
||||
for current_level < level {
|
||||
strings.write_string(&b, "<ul>\n")
|
||||
current_level += 1
|
||||
}
|
||||
} else if level < current_level {
|
||||
strings.write_string(&b, "</li>\n")
|
||||
for current_level > level {
|
||||
strings.write_string(&b, "</ul>\n</li>\n")
|
||||
current_level -= 1
|
||||
}
|
||||
} else {
|
||||
strings.write_string(&b, "</li>\n")
|
||||
}
|
||||
|
||||
strings.write_string(&b, `<li><a href="#`)
|
||||
strings.write_string(&b, id)
|
||||
strings.write_string(&b, `">`)
|
||||
strings.write_string(&b, text)
|
||||
strings.write_string(&b, `</a>`)
|
||||
}
|
||||
|
||||
if current_level == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
strings.write_string(&b, "</li>\n")
|
||||
for current_level > min_level {
|
||||
strings.write_string(&b, "</ul>\n</li>\n")
|
||||
current_level -= 1
|
||||
}
|
||||
strings.write_string(&b, "</ul>\n")
|
||||
|
||||
return strings.to_string(b)
|
||||
}
|
||||
|
||||
// next_heading finds the next <hN> tag with an id attribute starting from pos.
|
||||
// Returns level=0 if none found.
|
||||
next_heading :: proc(
|
||||
html: string,
|
||||
start: int,
|
||||
) -> (
|
||||
idx: int,
|
||||
level: int,
|
||||
id: string,
|
||||
text: string,
|
||||
next_pos: int,
|
||||
) {
|
||||
i := start
|
||||
for i + 3 < len(html) {
|
||||
if html[i] == '<' && html[i + 1] == 'h' {
|
||||
d := html[i + 2]
|
||||
if d >= '1' && d <= '6' {
|
||||
level = int(d - '0')
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
if level == 0 {
|
||||
return 0, 0, "", "", len(html)
|
||||
}
|
||||
|
||||
// Find end of opening tag
|
||||
tag_end := strings.index_byte(html[idx:], '>')
|
||||
if tag_end < 0 {
|
||||
return 0, 0, "", "", len(html)
|
||||
}
|
||||
tag_end += idx
|
||||
|
||||
// Find id="..." within the tag
|
||||
tag := html[idx:tag_end + 1]
|
||||
id_pos := strings.index(tag, `id="`)
|
||||
if id_pos < 0 {
|
||||
// No id — skip this heading, continue searching
|
||||
return next_heading(html, tag_end + 1)
|
||||
}
|
||||
|
||||
id_start := idx + id_pos + 4
|
||||
id_end_rel := strings.index_byte(html[id_start:], '"')
|
||||
if id_end_rel < 0 {
|
||||
return 0, 0, "", "", len(html)
|
||||
}
|
||||
id = html[id_start:id_start + id_end_rel]
|
||||
|
||||
// Text between > and </hN>
|
||||
text_start := tag_end + 1
|
||||
close_idx := strings.index(html[text_start:], "</h")
|
||||
if close_idx < 0 {
|
||||
return 0, 0, "", "", len(html)
|
||||
}
|
||||
text_end := text_start + close_idx
|
||||
text = strip_tags(html[text_start:text_end])
|
||||
|
||||
// Find end of closing tag
|
||||
next_pos = text_end + close_tag_len(html, text_end)
|
||||
|
||||
return idx, level, id, text, next_pos
|
||||
}
|
||||
|
||||
// close_tag_len returns the length of the </hN> tag at pos.
|
||||
close_tag_len :: proc(html: string, pos: int) -> int {
|
||||
if pos + 4 > len(html) {
|
||||
return 4
|
||||
}
|
||||
end := strings.index_byte(html[pos:], '>')
|
||||
if end < 0 {
|
||||
return 4
|
||||
}
|
||||
return end + 1
|
||||
}
|
||||
|
||||
// strip_tags removes HTML tags from a string, leaving only text content.
|
||||
strip_tags :: proc(s: string) -> string {
|
||||
b: strings.Builder
|
||||
strings.builder_init(&b, context.temp_allocator)
|
||||
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
if s[i] == '<' {
|
||||
end := strings.index_byte(s[i:], '>')
|
||||
if end >= 0 {
|
||||
i += end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
strings.write_byte(&b, s[i])
|
||||
i += 1
|
||||
}
|
||||
return strings.to_string(b)
|
||||
}
|
||||
|
||||
+88
-30
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"title": "Docs",
|
||||
"date": "2026-07-22T08:54:00-04:00"
|
||||
"date": "2026-07-22T08:54:00-04:00",
|
||||
"toc": true
|
||||
}
|
||||
|
||||
[TOC]
|
||||
|
||||
## Introduction
|
||||
|
||||
This guide assumes you have either read [The Guide](../guide), or have built a [Hugo](https://gohugo.io) site before. It also assumes you have a basic knowledge of HTML and CSS.
|
||||
@@ -32,33 +31,37 @@ TODO: Do we minify inline css?
|
||||
- syntax highlighting
|
||||
- heading ids
|
||||
- TODO: Table Of Contents Generation
|
||||
- [GitHub style alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
|
||||
|
||||
|
||||
## Directories
|
||||
Like Hugo, a Thor project is a collection of specially named directories, plus a config file.
|
||||
Like Hugo, a Thor project is a collection of specially named directories, plus an optional config file. All directories are optional, but it is recommended to at least have a `content` directory.
|
||||
|
||||
content
|
||||
|
||||
: `content` holds your pages and page bundles.
|
||||
|
||||
layouts
|
||||
|
||||
: `layouts` holds your templates and partials.
|
||||
: `content` holds your pages and page bundles. See [Content](#content). If not found, thor will look for content files in the root of your current working directory.
|
||||
|
||||
assets
|
||||
: `assets` contains any static files for your site (favicon.ico, etc.), as well as files you want to send through the asset pipeline (CSS or JS files).
|
||||
|
||||
layouts
|
||||
|
||||
: `layouts` holds your templates and partials. See [Templates](#templates).
|
||||
|
||||
public
|
||||
: `public` will contain your completed site.
|
||||
|
||||
All of these names can be remapped in `thor.json`.
|
||||
All of these names can be remapped in `thor.json`.[^remap]
|
||||
|
||||
> [!NOTE] While directories can be remapped at the site level, modules must (currently) adhere to the defaults.
|
||||
[^remap]: While directories can be remapped at the site level, modules must (currently) adhere to the defaults.
|
||||
|
||||
### Asset pipeline
|
||||
### Assets
|
||||
|
||||
Currently, there is only one asset processor, and that is [the minifier](#minify).
|
||||
Currently, there is only one asset processor, and that is [the minifier](#minify), though more are planned (i.e. Image processing).
|
||||
|
||||
TODO: Expand
|
||||
|
||||
## Content
|
||||
### Pages & Page Bundles
|
||||
|
||||
Page content can either be defined in a single file (`contact.md`), or in a directory (`contact/index.md` + `contact/our-team.jpg`). Single file pages are preferred to page bundles.[^1]
|
||||
@@ -67,21 +70,39 @@ Page content can either be defined in a single file (`contact.md`), or in a dire
|
||||
|
||||
Thor currently supports 2 formats for page files: MarkDown (`.md`), and HTML (`.html`).
|
||||
|
||||
TODO: Content
|
||||
|
||||
### Frontmatter
|
||||
|
||||
TODO: Frontmatter
|
||||
|
||||
## Menus
|
||||
|
||||
TODO: Describe
|
||||
|
||||
TODO: Don't forget to highlight differences from Hugo.
|
||||
|
||||
## Templates[^tempmod]
|
||||
|
||||
[^tempmod]: Template modification is an "advanced" feature, and shoud probably be discussed later in the page. (or possibly in the guide.)
|
||||
|
||||
Sites are built using one or more template files written in an extended version of [mustache](https://mustache.github.io) templates. The [mustache manual](https://mustache.github.io/mustache.5.html) has great explainations and a lot of examples if you want to know more, but I'll summarize them for you here.
|
||||
|
||||
The beauty of Mustache is that there is very little syntax; there are just 10 symbols you need learn: `{{`, `{{&`, `{{^`, `{{>`, `{{<`, `{{#`, `/}}`, `{{$`, `{{!`, and `|`.
|
||||
|
||||
TODO: ^^ Badly worded sentence ^^
|
||||
|
||||
TODOS: Gotta describe base templates somewhere. (the same way we describe the partials)
|
||||
|
||||
### Tags
|
||||
|
||||
#### Variables
|
||||
|
||||
In order do display a scalar (not-list) value in your template, simply wrap it in double curly braces. e.g. `{{ page.title }}`.
|
||||
In order to display a scalar (not-list) value in your template, simply wrap it in double curly braces. e.g. `{{ page.title }}`.
|
||||
|
||||
This content will be HTML escaped (for safety), so if the value you're rendering contains html, you'll need to use the raw syntex instead `{{& page.title}}` which will output the value without stripping or re-writing content.
|
||||
This content will be HTML escaped (for safety), so if the value you're rendering contains HTML, you'll need to use the raw syntex instead `{{& page.title}}`[^raw] which will output the value without stripping or re-writing content.
|
||||
|
||||
`{{{ raw }}}` syntax is supported for raw html output, but `{{& raw }}` is preferred, as it's easy to accidentily insert too many braces.
|
||||
[^raw]: Official Triple brace syntax (`{{{ raw }}}`) is also supported, but `{{& raw }}` is preferred, as it's easy to accidentily insert too many braces.
|
||||
|
||||
In most[^most] cases, invalid keys will be silently ignored (nothing between the braces will appear), in keeping with the official mustache spec.
|
||||
|
||||
@@ -118,7 +139,7 @@ TODO:
|
||||
|
||||
### Section Names
|
||||
|
||||
When building your own templates, you are of course free to pick whatever names you choose for your partials and content slots. However, sticking to conventions helps create consistency in the ecosystem, and reduces friction when relying on a built-in template.
|
||||
When building your own templates, you are of course free to pick whatever names you choose for your partials and content slots. However, sticking to conventions helps create consistency in the ecosystem, and reduces friction when relying on built-in templates.
|
||||
|
||||
`{{$main}}...{{/main}}`
|
||||
|
||||
@@ -144,13 +165,6 @@ When building your page(s), the following keys are accessible to your template f
|
||||
|
||||
: The Current `DateTime`. see [DateTime](#datetimes)
|
||||
|
||||
`title`
|
||||
|
||||
: The title of the current page. Unless overridden, it will be expand to
|
||||
`{{ page.title }} | {{ site.title }}`. [^title]
|
||||
|
||||
[^title]: Is there actually a way to overwrite this?
|
||||
|
||||
`date_format`
|
||||
|
||||
: The default format to use for dates. Configured in `thor.json:date.format`.
|
||||
@@ -167,10 +181,16 @@ When building your page(s), the following keys are accessible to your template f
|
||||
|
||||
: Returns all regular pages, sorted by `?`. Regular pages exclude index pages like home and section roots.
|
||||
|
||||
`posts`
|
||||
: TODO: Section groupings
|
||||
|
||||
`og`
|
||||
|
||||
: Contains the [Open Graph](https://ogp.me/) metadata for the current page.
|
||||
|
||||
`menus`
|
||||
|
||||
: The site's constructed menus. See [menus](#menus).
|
||||
#### Page
|
||||
|
||||
`page.content`
|
||||
@@ -191,6 +211,11 @@ TODO: Write
|
||||
|
||||
: The author of the current page or site. See [schema.org](https://schema.org/author) for the recommended format.
|
||||
|
||||
`stylesheets`
|
||||
|
||||
: A list of paths to css files the users wants to include globally. These are rendered by the `{{> styles }}` partial, and can be omitted on sites that use custom templates.
|
||||
|
||||
TODO: ^^ Bad sentence? ^^
|
||||
|
||||
#### The Context Stack
|
||||
When building your page(s), each template is fed a Context[^ctx] stack that contains all the data should you need to build your page.
|
||||
@@ -260,7 +285,7 @@ Because mustache is a logic-less language, (there are no `for` or `if` tags), Th
|
||||
|
||||
Thor allows you to chain two or more pipes, to allow complex data manipulation. However for performance and stylistic reasons, you are limited to no more than **8 pipes**[^pipes] for any given tag. If you believe you need more than 8 pipes, please [open an issue](../issues) with a **concrete example** of the problem you are facing.
|
||||
|
||||
[^pipes]: TODO: THis number must be kept in-sync with `MAX_PIPES`.
|
||||
[^pipes]: TODO: This number must be kept in-sync with `MAX_PIPES`.
|
||||
|
||||
**Examples:**
|
||||
|
||||
@@ -275,16 +300,53 @@ Thor allows you to chain two or more pipes, to allow complex data manipulation.
|
||||
|
||||
### Partials
|
||||
|
||||
Partials are templates that render a portion of a page. To include a partial, the standard [mustache syntax](https://mustache.github.io/mustache.5.html#Partials) is used. All partials are resolved relative to the root partials directory, so to include a partial at `layouts/partials/my_partial.html`, you would use `{{> my_partial}}`.
|
||||
Partials are templates that render a portion of a page. To include a partial, the standard [mustache syntax](https://mustache.github.io/mustache.5.html#Partials) is used. All partials are resolved relative to the root partial's directory, so to include a partial at `layouts/partials/my_partial.html`, you would use `{{> my_partial}}`.
|
||||
|
||||
Users can create or override as many partials as they want; several are included for convienience:
|
||||
|
||||
`{{> title }}`
|
||||
|
||||
: The title of the current page. It should be placed inside the `<title>` tag. By default, it will appear as
|
||||
`{{ page.title }} | {{ site.title }}`.
|
||||
|
||||
`{{> nav }}`
|
||||
|
||||
: Nav renders the main menu for the site (`menus.main`). It should be placed inside the `<body>` tag, just before the `<main>` block.
|
||||
|
||||
`{{> home-link }}`
|
||||
|
||||
: This partial is rendered inside the home anchor in `{{> nav }}`. By default, it wil display the name of the site, or `Home` if no name is set.
|
||||
|
||||
`{{> styles }}`
|
||||
|
||||
: This partial renders any stylesheets specified either by the user (via `params.stylesheets`) or by the theme author (specified directly in the template). `params.stylesheets` is intended as an escape hatch for users that want to add CSS to their site, but don't want to customize any templates. Styles should be placed inside the `<head>` tags.
|
||||
|
||||
`{{> scripts }}`
|
||||
|
||||
: This partial renders any script tags specified either by the user (via `params.scripts`) or by the theme author (specified directly in the template). `params.scripts` is intended as an escape hatch for users that want to add javascript to their site, but don't want to customize any templates. Scripts should be placed at the end of the `<head>` block.
|
||||
|
||||
TODO: Scripts must currently be given in raw form, whereas styles are just paths/urs.
|
||||
|
||||
`{{> opengraph }}`
|
||||
|
||||
: This partial willl render the Open Graph meta tags for your page. It should be placed inside the `<head>` tag. See the [Open Graph](#open-graph) section for more details.
|
||||
|
||||
`{{> footer }}`
|
||||
|
||||
: This partial will render content on every page after the main page content. It should be placed at the end of the `<body>` tag. By default, it displays a simple copyright line with the current year and author's name (configured in `params.author.name`).
|
||||
|
||||
TODO: `styles`/`css`?
|
||||
|
||||
TODO: `comments`?
|
||||
|
||||
TODO: `toc`?
|
||||
|
||||
TODO: We keep referring to blocks and tags, should probably use consistent language.
|
||||
|
||||
### DateTimes
|
||||
|
||||
TODO: Should this be a subsection of a "Data Types" section?
|
||||
|
||||
Dates are strings in one of the following formats:
|
||||
|
||||
| Format | Time zone |
|
||||
@@ -298,10 +360,6 @@ Dates are strings in one of the following formats:
|
||||
|
||||
If you want to display a date in a different format, you can use the `| format` Filter. With no argument, it will default to formatting the date with `site.date_format`.
|
||||
|
||||
## Menus
|
||||
|
||||
TODO: Describe
|
||||
|
||||
## Open Graph
|
||||
|
||||
TODO: default Template support
|
||||
|
||||
Reference in New Issue
Block a user