Skip to content

Commit 1e4514d

Browse files
committed
feat(render): add automatic heading ID generation
Provide navigation anchors by generating slugs for headings and Table of Contents links. This resolves broken links resulting from missing directives while maintaining support for manual ID overrides.
1 parent 61c51ed commit 1e4514d

27 files changed

Lines changed: 189 additions & 55 deletions

File tree

.github/workflows/release.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
build:
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
include:
17+
- os: ubuntu-latest
18+
target: x86_64-linux
19+
- os: ubuntu-latest
20+
target: aarch64-linux
21+
- os: macos-latest
22+
target: aarch64-macos
23+
- os: macos-latest
24+
target: x86_64-macos
25+
26+
runs-on: ${{ matrix.os }}
27+
steps:
28+
- uses: actions/checkout@v4
29+
with:
30+
fetch-depth: 0
31+
32+
- name: Setup Zig
33+
uses: mlugg/setup-zig@v2
34+
35+
- name: Build
36+
run: zig build -Doptimize=ReleaseFast -Dtarget=${{ matrix.target }}
37+
38+
- name: Package
39+
run: |
40+
mkdir -p dist
41+
cp zig-out/bin/zine dist/zine
42+
cd dist
43+
tar -czvf ../zine-${{ github.ref_name }}-${{ matrix.target }}.tar.gz zine
44+
45+
- name: Upload Release Assets
46+
uses: softprops/action-gh-release@v2
47+
with:
48+
files: zine-${{ github.ref_name }}-${{ matrix.target }}.tar.gz
49+
env:
50+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

GEMINI.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Zine: Static Site Generator
2+
3+
Zine is a fast, scalable, and flexible Static Site Generator (SSG) written in Zig. It is designed for performance and provides a modern development experience with live reloading and advanced templating.
4+
5+
## Project Overview
6+
7+
- **Language:** Zig (Minimum version 0.15.0)
8+
- **Markdown Engine:** [supermd](https://github.com/kristoff-it/supermd)
9+
- **HTML Engine:** [superhtml](https://github.com/kristoff-it/superhtml)
10+
- **Data Format:** [ziggy](https://github.com/kristoff-it/ziggy)
11+
- **Core Architecture:**
12+
- `src/main.zig`: Entry point, dispatches CLI subcommands.
13+
- `src/root.zig`: Core logic for configuration loading, content scanning, and site building.
14+
- `src/worker.zig`: Handles concurrent tasks for scaling.
15+
- `src/cli/`: Implementation of subcommands (`init`, `release`, `debug`, `serve`).
16+
- `src/render/`: HTML rendering logic for Markdown content.
17+
- `src/context/`: Data models for Site, Page, Assets, etc., available in templates.
18+
19+
## Building and Running
20+
21+
### Development Commands
22+
23+
- **Build Zine:** `zig build`
24+
- **Check Build:** `zig build check` (faster than full build, only checks the executable)
25+
- **Run Tests:** `zig build test` (runs snapshot tests in `tests/`)
26+
- **Run on Test Site:** `zig build run` (runs `zine` against the `standalone-test` directory)
27+
28+
### CLI Usage
29+
30+
Once built, you can use the `zine` executable (found in `zig-out/bin/`):
31+
32+
- **Initialize a new site:** `zine init` (optionally with `--multilingual`)
33+
- **Start dev server:** `zine` (default command, serves on `localhost:8000`)
34+
- **Build for release:** `zine release` (outputs to `public/` by default)
35+
- **Version info:** `zine version`
36+
37+
## Development Conventions
38+
39+
- **Snapshot Testing:** Zine relies heavily on snapshot tests located in `tests/`. These tests compare actual output against `snapshot.txt` files to prevent regressions.
40+
- **Strict Typing:** Uses Zig's type system extensively to ensure correctness, particularly with `ziggy` schemas for configuration and frontmatter.
41+
- **Performance:** Designed to be multi-threaded; `worker.zig` manages parallel processing of pages.
42+
- **Template System:** Uses `superhtml` for layouts. Templates have access to `$site`, `$page`, and custom properties defined in frontmatter.
43+
44+
## Key Files
45+
46+
- `zine.ziggy`: The main configuration file for a Zine project.
47+
- `frontmatter.ziggy-schema`: Defines the schema for page metadata.
48+
- `src/root.zig`: Contains the `Site` and `Config` structs which define the project structure.
49+
- `build.zig`: Defines the build process and test runners.

build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
.name = .zine,
33
.version = "0.11.2",
44
.fingerprint = 0xa466bcb520a7eea2,
5-
.minimum_zig_version = "0.15.0",
5+
.minimum_zig_version = "0.16.0",
66
.dependencies = .{
77
.lsp_kit = .{
88
.url = "git+https://github.com/zigtools/lsp-kit#ec325a3c33d1da7708cf513355208f74d9560580",

src/context/Page.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1782,7 +1782,7 @@ pub const Builtins = struct {
17821782
const w = &aw.writer;
17831783

17841784
const ast = p._parse.ast;
1785-
render.htmlToc(ast, w) catch return error.OutOfMemory;
1785+
render.htmlToc(gpa, ast, w) catch return error.OutOfMemory;
17861786

17871787
return String.init(aw.written());
17881788
}

src/render/html.zig

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,34 @@ const HtmlSafe = @import("superhtml").HtmlSafe;
1717

1818
const log = std.log.scoped(.render);
1919

20+
fn renderSlug(gpa: std.mem.Allocator, node: supermd.Node, w: *Writer) !void {
21+
_ = gpa;
22+
const text = try node.renderPlaintext();
23+
24+
var last_was_dash = false;
25+
var first = true;
26+
for (text) |byte| {
27+
// Allow alphanumeric ASCII and all non-ASCII (UTF-8) characters
28+
const is_alphanumeric = std.ascii.isAlphanumeric(byte);
29+
const is_non_ascii = byte >= 128;
30+
31+
if (is_alphanumeric or is_non_ascii) {
32+
if (last_was_dash and !first) try w.writeByte('-');
33+
if (is_alphanumeric) {
34+
try w.writeByte(std.ascii.toLower(byte));
35+
} else {
36+
// Pass UTF-8 bytes through as-is
37+
try w.writeByte(byte);
38+
}
39+
last_was_dash = false;
40+
first = false;
41+
} else {
42+
// ASCII symbols and whitespace become dashes
43+
last_was_dash = true;
44+
}
45+
}
46+
}
47+
2048
pub fn html(
2149
gpa: std.mem.Allocator,
2250
ctx: *const context.Template,
@@ -191,7 +219,7 @@ pub fn html(
191219
else => {},
192220
.heading => {
193221
try w.print("<h{}", .{node.headingLevel()});
194-
try w.print(" id=\"{s}\"", .{d.id.?});
222+
if (d.id) |id| try w.print(" id=\"{s}\"", .{id});
195223
if (d.attrs) |attrs| {
196224
try w.print(" class=\"", .{});
197225
for (attrs) |attr| try w.print("{s} ", .{attr});
@@ -207,7 +235,7 @@ pub fn html(
207235
}
208236
open_div = true;
209237
try w.print("<div", .{});
210-
try w.print(" id=\"{s}\"", .{d.id.?});
238+
if (d.id) |id| try w.print(" id=\"{s}\"", .{id});
211239
if (d.attrs) |attrs| {
212240
try w.print(" class=\"", .{});
213241
for (attrs) |attr| try w.print("{s} ", .{attr});
@@ -218,7 +246,10 @@ pub fn html(
218246
},
219247
};
220248

221-
try w.print("<h{}>", .{node.headingLevel()});
249+
try w.print("<h{}", .{node.headingLevel()});
250+
try w.writeAll(" id=\"");
251+
try renderSlug(gpa, node, w);
252+
try w.writeAll("\">");
222253
},
223254
.exit => {
224255
if (node.parent()) |p| if (p.getDirective()) |pd| switch (pd.kind) {
@@ -759,7 +790,7 @@ fn renderLink(
759790
}
760791
}
761792

762-
pub fn htmlToc(ast: Ast, w: *Writer) !void {
793+
pub fn htmlToc(gpa: std.mem.Allocator, ast: Ast, w: *Writer) !void {
763794
try w.print("<ul>\n", .{});
764795
var lvl: i32 = 1;
765796
var first_item = true;
@@ -777,21 +808,21 @@ pub fn htmlToc(ast: Ast, w: *Writer) !void {
777808
try w.print("<ul><li>\n", .{});
778809
}
779810

780-
try tocRenderHeading(n, w, true);
811+
try tocRenderHeading(gpa, n, w, true);
781812
} else if (new_lvl < lvl) {
782813
try w.print("</li>", .{});
783814
while (new_lvl < lvl) : (lvl -= 1) {
784815
try w.print("</ul></li>", .{});
785816
}
786817
try w.print("<li>", .{});
787-
try tocRenderHeading(n, w, true);
818+
try tocRenderHeading(gpa, n, w, true);
788819
} else {
789820
if (first_item) {
790821
try w.print("<li>", .{});
791-
try tocRenderHeading(n, w, true);
822+
try tocRenderHeading(gpa, n, w, true);
792823
} else {
793824
try w.print("</li><li>", .{});
794-
try tocRenderHeading(n, w, true);
825+
try tocRenderHeading(gpa, n, w, true);
795826
}
796827
}
797828
}
@@ -803,7 +834,7 @@ pub fn htmlToc(ast: Ast, w: *Writer) !void {
803834
try w.print("</ul>", .{});
804835
}
805836

806-
fn tocRenderHeading(heading: supermd.Node, w: *Writer, link: bool) !void {
837+
fn tocRenderHeading(gpa: std.mem.Allocator, heading: supermd.Node, w: *Writer, link: bool) !void {
807838
var it = Iter.init(heading);
808839
while (it.next()) |ev| {
809840
const node = ev.node;
@@ -814,18 +845,22 @@ fn tocRenderHeading(heading: supermd.Node, w: *Writer, link: bool) !void {
814845
),
815846
.HEADING => switch (ev.dir) {
816847
.enter => {
817-
const dir = node.getDirective() orelse continue;
818-
if (dir.id) |id| {
819-
std.debug.assert(id.len > 0);
820-
std.debug.assert(std.mem.trim(u8, id, "\t\n\r ").len > 0);
821-
if (link) try w.print("<a href=\"#{s}\">", .{id});
848+
if (link) {
849+
try w.writeAll("<a href=\"#");
850+
if (node.getDirective()) |d| {
851+
if (d.id) |id| {
852+
try w.writeAll(id);
853+
} else {
854+
try renderSlug(gpa, node, w);
855+
}
856+
} else {
857+
try renderSlug(gpa, node, w);
858+
}
859+
try w.writeAll("\">");
822860
}
823861
},
824862
.exit => {
825-
const dir = node.getDirective() orelse continue;
826-
if (dir.id != null) {
827-
if (link) try w.print("</a>", .{});
828-
}
863+
if (link) try w.print("</a>", .{});
829864
},
830865
},
831866
.TEXT => switch (ev.dir) {
@@ -861,7 +896,7 @@ fn tocRenderHeading(heading: supermd.Node, w: *Writer, link: bool) !void {
861896
}
862897
}
863898

864-
pub fn htmlTocDetails(ast: Ast, w: *Writer) !void {
899+
pub fn htmlTocDetails(gpa: std.mem.Allocator, ast: Ast, w: *Writer) !void {
865900
var lvl: i32 = 1;
866901
var first_item = true;
867902
var node: ?supermd.Node = ast.md.root.firstChild();
@@ -880,7 +915,7 @@ pub fn htmlTocDetails(ast: Ast, w: *Writer) !void {
880915
}
881916

882917
// if (lvl == 1) try w.print("<summary>\n", .{});
883-
try tocRenderHeading(n, w, true);
918+
try tocRenderHeading(gpa, n, w, true);
884919
// if (lvl == 1) try w.print("</summary>\n", .{});
885920
} else if (new_lvl < lvl) {
886921
try w.print("</li>", .{});
@@ -889,30 +924,30 @@ pub fn htmlTocDetails(ast: Ast, w: *Writer) !void {
889924
}
890925
if (lvl == 1) {
891926
try w.print("</details><details><summary>", .{});
892-
try tocRenderHeading(n, w, false);
927+
try tocRenderHeading(gpa, n, w, false);
893928
try w.print("</summary>", .{});
894929
} else {
895930
try w.print("<li>", .{});
896-
try tocRenderHeading(n, w, true);
931+
try tocRenderHeading(gpa, n, w, true);
897932
}
898933
} else {
899934
if (first_item) {
900935
if (lvl == 1) {
901936
try w.print("<details><summary>", .{});
902-
try tocRenderHeading(n, w, false);
937+
try tocRenderHeading(gpa, n, w, false);
903938
try w.print("</summary>", .{});
904939
} else {
905940
try w.print("<li>", .{});
906-
try tocRenderHeading(n, w, true);
941+
try tocRenderHeading(gpa, n, w, true);
907942
}
908943
} else {
909944
if (lvl == 1) {
910945
try w.print("</details><details><summary>", .{});
911-
try tocRenderHeading(n, w, false);
946+
try tocRenderHeading(gpa, n, w, false);
912947
try w.print("</summary>", .{});
913948
} else {
914949
try w.print("</li><li>", .{});
915-
try tocRenderHeading(n, w, true);
950+
try tocRenderHeading(gpa, n, w, true);
916951
}
917952
}
918953
}

tests/drafts/simple/snapshot/archive/2024/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
</head>
77
<body>
88
<h1>2024</h1>
9-
<div><h1>2024</h1><p>Lorem ipsum</p></div>
9+
<div><h1 id="2024">2024</h1><p>Lorem ipsum</p></div>
1010
</body>
1111
</html>

tests/drafts/simple/snapshot/archive/2025/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
</head>
77
<body>
88
<h1>2025</h1>
9-
<div><h1>2025</h1><p>Lorem ipsum</p></div>
9+
<div><h1 id="2025">2025</h1><p>Lorem ipsum</p></div>
1010
</body>
1111
</html>

tests/drafts/simple/snapshot/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
</head>
77
<body>
88
<h1>Homepage</h1>
9-
<div><p>Your <strong>SuperMD</strong> content goes here.</p><h1>H1</h1><p>Lorem Ipsum 1</p><h2>H2</h2><p>Lorem Ipsum 2</p><h4>H3</h4><p>Lorem Ipsum 3</p></div>
9+
<div><p>Your <strong>SuperMD</strong> content goes here.</p><h1 id="h1">H1</h1><p>Lorem Ipsum 1</p><h2 id="h2">H2</h2><p>Lorem Ipsum 2</p><h4 id="h3">H3</h4><p>Lorem Ipsum 3</p></div>
1010
</body>
1111
</html>

tests/drafts/simple/snapshot/sections/index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ <h2>-- TABLE OF CONTENTS --</h2>
1515
</div>
1616
<div>
1717
<h2>-- SECTION BEGIN --</h2>
18-
<div><div id="h1"><h1><a class="" href="#h1">H1</a></h1><p>Lorem Ipsum 1</p></div></div>
18+
<div><div id="h1"><h1 id="h1"><a class="" href="#h1">H1</a></h1><p>Lorem Ipsum 1</p></div></div>
1919
<h2>-- SECTION END --</h2>
2020

2121
<h2>-- SECTION BEGIN --</h2>
22-
<div><div id="h2"><h2><a class="" href="#h2">H2</a></h2><p>Lorem Ipsum 2</p></div></div>
22+
<div><div id="h2"><h2 id="h2"><a class="" href="#h2">H2</a></h2><p>Lorem Ipsum 2</p></div></div>
2323
<h2>-- SECTION END --</h2>
2424

2525
<h2>-- SECTION BEGIN --</h2>
26-
<div><div id="h3"><h3><a class="" href="#h3">H3</a></h3><p>Lorem Ipsum 3</p><p>This is a footnote<sup class="footnote-ref"><a href="#fn-1" id="fn-1-ref-1">1</a></sup></p></div></div>
26+
<div><div id="h3"><h3 id="h3"><a class="" href="#h3">H3</a></h3><p>Lorem Ipsum 3</p><p>This is a footnote<sup class="footnote-ref"><a href="#fn-1" id="fn-1-ref-1">1</a></sup></p></div></div>
2727
<h2>-- SECTION END --</h2>
2828
</div>
2929
</body>

tests/rendering/multi/snapshot/about/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ <h1 class="site-title">Test</h1>
2121
</nav>
2222

2323
<h1>About</h1>
24-
<div><h2>About Page (en-US)</h2></div>
24+
<div><h2 id="about-page-en-us">About Page (en-US)</h2></div>
2525

2626
<div style="font-size: 2em;">
2727
<a href="/about/">en-US</a>

0 commit comments

Comments
 (0)