12 Commits

Author SHA1 Message Date
1f93e24a37 fix: free Document only if allocated otherwise treat as static
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m10s
2026-01-17 21:42:00 +01:00
0ec9839cc7 mod: bump zlog dependency 2026-01-17 21:41:43 +01:00
9ae9dcfce2 mod(nav): button automatically resizes as necessary 2026-01-17 21:39:51 +01:00
db8485d88e lint: correct formatting
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 59s
2026-01-17 13:27:57 +01:00
8199a23566 ref: move Model manipulation logic in own Element *website*
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 47s
This way no `Element` that is only there for rendering requires
any internal state.
2026-01-17 13:25:26 +01:00
83d83c17fa mod: make Element's for rendering stateless
The elements derive their corresponding content to render from
the `Model` without any local state.
2026-01-17 13:11:26 +01:00
1b4c1bbab8 mod(ci): update release process to also include spell-checking 2026-01-17 12:42:09 +01:00
361623d0f1 doc: remove warning about required zig version
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 54s
2026-01-17 12:31:23 +01:00
6bf4c98d44 mod(ci): adapt to use zig latest release version
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m13s
2026-01-17 12:28:29 +01:00
bfdaf82626 mod: bump zterm dependency with required code adaptations
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m16s
2026-01-17 12:26:36 +01:00
b4836b8d2b mod: bump to version 0.0.4
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 55s
Release Zig Application / Release zig project (release) Successful in 1m36s
2025-11-08 12:54:20 +01:00
5ee2c6662e feat: invalid page for unavailable resources
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m46s
Accessing resources which are not available, the invalid page will be
rendered instead. The original attempt for access will be logged.
2025-11-08 12:50:25 +01:00
8 changed files with 108 additions and 86 deletions

View File

@@ -15,9 +15,15 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup zig installation
uses: mlugg/setup-zig@v2
uses: https://codeberg.org/mlugg/setup-zig@v2
with:
version: master
version: latest
- name: Lint check
run: zig fmt --check .
- name: Spell checking
uses: crate-ci/typos@v1.39.0
with:
config: ./.typos-config
- name: Run tests
run: zig build --release=fast
- name: Release build artifacts

View File

@@ -14,9 +14,9 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup zig installation
uses: mlugg/setup-zig@v2
uses: https://codeberg.org/mlugg/setup-zig@v2
with:
version: master
version: latest
- name: Lint check
run: zig fmt --check .
- name: Spell checking

View File

@@ -4,9 +4,6 @@ This is my terminal based website. It is served as a tui application via ssh and
It contains information about me and my projects as well as blog entries about something I feel like writing something about.
> [!caution]
> Only builds using the zig master version are tested to work.
## zterm
This tui application also serves as an example implementation of the [zterm](https://gitea.yves-biener.de/yves-biener/zterm) library.

View File

@@ -1,17 +1,17 @@
.{
.name = .tui_website,
// This is a [Semantic Version](https://semver.org/).
.version = "0.0.3",
.version = "0.0.4",
.fingerprint = 0x93d98a4d9d000e9c, // Changing this has security and trust implications.
.minimum_zig_version = "0.16.0-dev.463+f624191f9",
.dependencies = .{
.zterm = .{
.url = "git+https://gitea.yves-biener.de/yves-biener/zterm#ad32e46bc9fd6d1d9b7a3615979a3b80877c9300",
.hash = "zterm-0.3.0-1xmmEO8PHABP4tE6BnEZllJTh6Hpza_5C9wS8s2vjwou",
.url = "git+https://gitea.yves-biener.de/yves-biener/zterm#4874252e8c3a5d645ac2c37bbfd0e669964912fc",
.hash = "zterm-0.3.0-1xmmEH82HABfsn5imAK8lw93LevIaqNV10g6xfkNj_OT",
},
.zlog = .{
.url = "git+https://gitea.yves-biener.de/yves-biener/zlog#f43034cea9a0863e618c3d0a43706ce38c8791cf",
.hash = "zlog-0.16.0-6JSlRx1JAADasbK5FS6Qaf0Iq1SCQaH5VZRxT2SRE2xs",
.url = "git+https://gitea.yves-biener.de/yves-biener/zlog#b3c8a3ab1ccae1ba50efbdf26d68a18da8594d2c",
.hash = "zlog-0.16.0-6JSlRwNJAACVd89xnhXRHzCnCs7qab9Jcmx11VMS2ZpL",
},
},
.paths = .{

View File

@@ -1,74 +1,67 @@
pub fn Content(App: type) type {
pub fn Website(App: type) type {
return struct {
allocator: Allocator,
document: *const App.Model.Document,
gpa: Allocator,
pub fn init(allocator: Allocator, document: *const App.Model.Document) @This() {
return .{
.allocator = allocator,
.document = document,
};
pub fn init(gpa: Allocator) @This() {
return .{ .gpa = gpa };
}
pub fn element(this: *@This()) App.Element {
return .{
.ptr = this,
.vtable = &.{
.minSize = minSize,
.handle = handle,
.content = content,
},
};
}
fn minSize(ctx: *anyopaque, size: zterm.Point) zterm.Point {
const this: *const @This() = @ptrCast(@alignCast(ctx));
const text = this.document.content;
var index: usize = 0;
var new_size: zterm.Point = .{ .x = size.x };
for (0..text.len) |_| rows: {
for (0..size.x - 1) |_| { // assume that there is one cell reserved for the scrollbar
if (index == text.len) break :rows;
const cp = text[index];
index += 1;
switch (cp) {
'\n' => break,
else => {},
}
}
new_size.y += 1;
}
return new_size;
}
fn handle(ctx: *anyopaque, model: *App.Model, event: App.Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
.init => this.document = &model.document,
.about => {
model.page.deinit(this.allocator);
model.page.deinit(this.gpa);
model.page = .about;
model.document.deinit(this.allocator);
model.document = .init(try std.fs.cwd().readFileAlloc("./doc/about.md", this.allocator, .unlimited));
this.document = &model.document;
model.document.deinit(this.gpa);
model.document = .init(try std.fs.cwd().readFileAlloc(this.gpa, "./doc/about.md", std.math.maxInt(usize)));
},
.blog => |path| {
model.page.deinit(this.allocator);
model.page = .{ .blog = path };
model.page.deinit(this.gpa);
model.document.deinit(this.gpa);
errdefer {
if (path) |p| this.gpa.free(p);
model.document = .invalidPage;
model.page = .{ .blog = null };
}
model.document.deinit(this.allocator);
model.document = .init(try std.fs.cwd().readFileAlloc(if (path) |p| p else "./doc/blog.md", this.allocator, .unlimited));
this.document = &model.document;
model.document = .init(try std.fs.cwd().readFileAlloc(this.gpa, if (path) |p| p else "./doc/blog.md", std.math.maxInt(usize)));
model.page = .{ .blog = path };
},
else => {},
}
}
};
}
fn content(ctx: *anyopaque, model: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void {
_ = ctx;
pub fn Content(App: type) type {
return struct {
pub fn element(this: *@This()) App.Element {
return .{
.ptr = this,
.vtable = &.{
.minSize = minSize,
.content = content,
},
};
}
fn minSize(_: *anyopaque, model: *const App.Model, _: zterm.Point) zterm.Point {
// NOTE currently the contents are assumed to fit into the viewport, excessive lines are cut off
// (there currently no horizontal scrolling)
return .{ .y = @intCast(std.mem.count(u8, model.document.content, "\n") + 1) };
}
fn content(_: *anyopaque, model: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void {
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
const text = model.document.content;
@@ -87,6 +80,10 @@ pub fn Content(App: type) type {
else => cp,
};
}
if (text[index - 1] != '\n') {
// go to the end of the line
while (index < text.len and text[index] != '\n') index += 1;
}
}
}
};
@@ -103,9 +100,7 @@ pub fn Title(App: type) type {
};
}
fn content(ctx: *anyopaque, model: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void {
const this: *const @This() = @ptrCast(@alignCast(ctx));
_ = this;
fn content(_: *anyopaque, model: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void {
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
if (model.document.title) |text| {
@@ -123,10 +118,10 @@ pub fn Title(App: type) type {
}
pub fn InfoBanner(App: type) type {
return struct {
left_text: []const u8 = "Build with zig",
right_text: []const u8 = "Yves Biener (@yves-biener)",
const left_text: []const u8 = "Build with zig";
const right_text: []const u8 = "Yves Biener (@yves-biener)";
return struct {
pub fn element(this: *@This()) App.Element {
return .{
.ptr = this,
@@ -136,11 +131,10 @@ pub fn InfoBanner(App: type) type {
};
}
fn content(ctx: *anyopaque, model: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void {
const this: *const @This() = @ptrCast(@alignCast(ctx));
fn content(_: *anyopaque, model: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void {
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
for (0.., this.left_text) |idx, cp| {
for (0.., left_text) |idx, cp| {
// NOTE do not write over the contents of this `Container`'s `Size`
if (idx == cells.len) break;
@@ -153,7 +147,7 @@ pub fn InfoBanner(App: type) type {
.about => {},
.blog => |path| if (path) |p| page: {
const start_idx = (size.x -| p.len) / 2;
if (start_idx <= this.left_text.len) break :page;
if (start_idx <= left_text.len) break :page;
for (start_idx.., p) |idx, cp| {
// NOTE do not write over the contents of this `Container`'s `Size`
@@ -165,10 +159,10 @@ pub fn InfoBanner(App: type) type {
},
}
var start_idx = size.x -| this.right_text.len;
if (start_idx <= this.left_text.len) start_idx = this.left_text.len + 1;
var start_idx = size.x -| right_text.len;
if (start_idx <= left_text.len) start_idx = left_text.len + 1;
for (start_idx.., this.right_text) |idx, cp| {
for (start_idx.., right_text) |idx, cp| {
// NOTE do not write over the contents of this `Container`'s `Size`
if (idx == cells.len) break;
@@ -184,3 +178,4 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const zterm = @import("zterm");
const Model = @import("model.zig");

View File

@@ -1,4 +1,11 @@
// usage: tui_website <path>
// TODO
// - should the path containing the contents of the tui-website reside in the
// hardcode `./doc` directory or shall this become a compilation argument to
// allow for custom directory locations?
// usage: tui_website <location>
// <location>: partial path to the document without the file extension and the beginning `./doc` path
// ending `/` (as used from the web-world) are trimmed automatically
pub fn main() !void {
errdefer |err| log.err("Application Error: {any}", .{err});
@@ -13,25 +20,25 @@ pub fn main() !void {
// skip own executable name
_ = arg_it.skip();
var app: App = .init(.{
var app: App = .init(.{}, .{
.page = .about,
.document = .init(try std.fs.cwd().readFileAlloc("./doc/about.md", allocator, .unlimited)),
.document = .init(try std.fs.cwd().readFileAlloc(allocator, "./doc/about.md", std.math.maxInt(usize))),
});
defer app.model.deinit(allocator);
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
var website: Website = .init(allocator);
var container = try App.Container.init(allocator, .{
.layout = .{
.padding = .horizontal(2),
.direction = .vertical,
.separator = .{ .enabled = true },
},
}, .{});
}, website.element());
defer container.deinit();
var content_container: App.Container = undefined;
defer content_container.deinit();
// header with navigation buttons and content's title
{
@@ -54,7 +61,6 @@ pub fn main() !void {
var button: NavigationButton(.about) = .init(&app.model, &app.queue);
try header.append(try .init(allocator, .{
.size = .{
.dim = .{ .x = 5 + 2 },
.grow = .vertical,
},
}, button.element()));
@@ -64,7 +70,6 @@ pub fn main() !void {
var button: NavigationButton(.blog) = .init(&app.model, &app.queue);
try header.append(try .init(allocator, .{
.size = .{
.dim = .{ .x = 4 + 2 },
.grow = .vertical,
},
}, button.element()));
@@ -73,7 +78,7 @@ pub fn main() !void {
}
// main actual tui_website page content
{
var content: Content = .init(allocator, &app.model.document);
var content: Content = .{};
content_container = try .init(allocator, .{}, content.element());
var scrollable: App.Scrollable = .init(content_container, .enabled(.green, false));
@@ -106,7 +111,11 @@ pub fn main() !void {
// -> enforce a specific structure?
// -> in case an invalid path is provided the 404 error page shall be shown
// -> reporte an corresponding error! (such that I can see that in the log)
app.postEvent(.{ .blog = try allocator.dupe(u8, path) });
var blog = path[0..path.len];
blog = std.mem.trimStart(u8, blog, "./");
blog = std.mem.trimEnd(u8, blog, ".md");
blog = std.mem.trimEnd(u8, blog, "/");
app.postEvent(.{ .blog = try std.fmt.allocPrint(allocator, "./doc/{s}.md", .{blog}) });
}
arg_it.deinit();
@@ -127,10 +136,12 @@ pub fn main() !void {
// pre event handling
switch (event) {
.key => |key| {
if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } })) app.quit();
if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = 'q' })) app.quit();
// test if the event handling is working correctly
if (key.eql(.{ .cp = zterm.input.Space })) app.postEvent(.{ .blog = allocator.dupe(u8, "./doc/test.md") catch unreachable });
if (key.eql(.{ .cp = zterm.input.Space })) app.postEvent(.{ .blog = try allocator.dupe(u8, "./doc/test.md") });
},
.about => log.info(ResourceRequestFormat, .{"./doc/about.md"}),
.blog => |path| log.info(ResourceRequestFormat, .{if (path) |p| p else "./doc/blog.md"}),
.err => |err| log.err("Received {s} with message: {s}", .{ @errorName(err.err), err.msg }),
else => {},
}
@@ -149,9 +160,8 @@ pub fn main() !void {
}
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}
@@ -161,6 +171,8 @@ pub const panic = App.panic_handler;
pub const std_options = zlog.std_options;
const log = std.log.scoped(.default);
const ResourceRequestFormat = "Requesting resource: `{s}`";
const std = @import("std");
const assert = std.debug.assert;
const zlog = @import("zlog");
@@ -172,6 +184,7 @@ const App = zterm.App(
const contents = @import("content.zig");
const Model = @import("model.zig");
const Website = contents.Website(App);
const Content = contents.Content(App);
const Title = contents.Title(App);
const InfoBanner = contents.InfoBanner(App);

View File

@@ -26,13 +26,19 @@ pub const Document = struct {
title: ?[]const u8 = null,
date: ?[]const u8 = null,
content: []const u8 = undefined,
ptr: []const u8,
ptr: ?[]const u8,
const Preemble = enum {
title,
date,
};
pub const invalidPage: @This() = .{
.title = "Page not found",
.content = "Requested page does not exist",
.ptr = null,
};
pub fn init(content: []const u8) @This() {
if (std.mem.startsWith(u8, content, "---\n")) {
var document: @This() = .{ .ptr = content };
@@ -61,8 +67,8 @@ pub const Document = struct {
}
pub fn deinit(this: *@This(), allocator: Allocator) void {
allocator.free(this.ptr);
this.* = .{ .ptr = undefined };
if (this.ptr) |ptr| allocator.free(ptr);
this.* = .{ .ptr = null };
}
};

View File

@@ -22,12 +22,17 @@ pub fn NavigationButton(App: type) fn (std.meta.FieldEnum(App.Event)) type {
return .{
.ptr = this,
.vtable = &.{
.minSize = minSize,
.handle = handle,
.content = content,
},
};
}
fn minSize(_: *anyopaque, _: *const App.Model, _: zterm.Point) zterm.Point {
return .{ .x = @tagName(page).len + 2 };
}
fn handle(ctx: *anyopaque, _: *App.Model, event: App.Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {