Implement Markdown

Create `.md` files instead of `.zmpl` files to render static markdown
content.
This commit is contained in:
Bob Farrell 2024-03-24 16:12:06 +00:00
parent 8d0a3f40d0
commit 0e29934718
12 changed files with 304 additions and 29 deletions

View File

@ -44,6 +44,8 @@ pub fn build(b: *std.Build) !void {
// b.dependency("jetzig",.{}).builder.dependency("zmpl",.{}).module("zmpl");
b.modules.put("zmpl", zmpl_dep.module("zmpl")) catch @panic("Out of memory");
const zmd_dep = b.dependency("zmd", .{ .target = target, .optimize = optimize });
const ZmplBuild = @import("zmpl").ZmplBuild;
const ZmplTemplate = @import("zmpl").Template;
var zmpl_build = ZmplBuild.init(b, lib, template_path);
@ -60,6 +62,7 @@ pub fn build(b: *std.Build) !void {
lib.root_module.addImport("zmpl", zmpl_module);
jetzig_module.addImport("zmpl", zmpl_module);
jetzig_module.addImport("args", zig_args_dep.module("args"));
jetzig_module.addImport("zmd", zmd_dep.module("zmd"));
const main_tests = b.addTest(.{
.root_source_file = .{ .path = "src/tests.zig" },
@ -148,6 +151,7 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
exe_static_routes.root_module.addImport("routes", routes_module);
exe_static_routes.root_module.addImport("jetzig", jetzig_module);
exe_static_routes.root_module.addImport("zmpl", zmpl_module);
exe_static_routes.root_module.addImport("jetzig_app", &exe.root_module);
const run_static_routes_cmd = b.addRunArtifact(exe_static_routes);
exe.step.dependOn(&run_static_routes_cmd.step);

View File

@ -2,6 +2,10 @@
.name = "jetzig",
.version = "0.0.0",
.dependencies = .{
.zmd = .{
.url = "https://github.com/jetzig-framework/zmd/archive/1a526ca1cf577789ca15ca1b71d3e943b81fb801.tar.gz",
.hash = "1220bfc5c29bc930b5a524c210712ef65c6cde6770450899bef01164a3089e6707fa",
},
.zmpl = .{
.url = "https://github.com/jetzig-framework/zmpl/archive/9e2df115c9f17e92fb60a4d09bf55ea48d0388b0.tar.gz",
.hash = "1220820b7f5f3e01b7dc976d32cf9ff65d44dee2642533f4b8104e19a824e802d7e1",

3
demo/public/prism.css Normal file
View File

@ -0,0 +1,3 @@
/* PrismJS 1.29.0
https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+css+clike+javascript+json+zig&plugins=file-highlight */
code[class*=language-],pre[class*=language-]{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}

10
demo/public/prism.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,12 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/prism.css" />
</head>
<body>
<main>{zmpl.content}</main>
<script src="/prism.js"></script>
</body>
</html>

View File

@ -0,0 +1,9 @@
const std = @import("std");
const jetzig = @import("jetzig");
pub const layout = "application";
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.ok);
}

View File

@ -0,0 +1,66 @@
# Markdown Example
![jetzig logo](https://www.jetzig.dev/jetzig.png)
_Markdown_ is rendered by _[zmd](https://github.com/jetzig-framework/zmd)_.
You can use a `StaticRequest` in your view if you prefer to render your _Markdown_ at build time, or use `Request` in development to render at run time without a server restart.
Simply create a `.md` file instead of a `.zmpl` file, e.g. `src/app/views/iguanas/index.md` and _Markdown_ will be rendered.
## An _ordered_ list
1. List item with a [link](https://ziglang.org/)
1. List item with some **bold** and _italic_ text
1. List item 3
## An _unordered_ list
* List item 1
* List item 2
* List item 3
## Define your own formatters in `src/main.zig`
```zig
pub const jetzig_options = struct {
pub const markdown_fragments = struct {
pub const root = .{
"<div class='p-5'>",
"</div>",
};
pub const h1 = .{
"<h1 class='text-2xl mb-3 font-bold'>",
"</h1>",
};
pub const h2 = .{
"<h2 class='text-xl mb-3 font-bold'>",
"</h2>",
};
pub const h3 = .{
"<h3 class='text-lg mb-3 font-bold'>",
"</h3>",
};
pub const paragraph = .{
"<p class='p-3'>",
"</p>",
};
pub const code = .{
"<span class='font-mono bg-gray-900 p-2 text-white'>",
"</span>",
};
pub fn block(allocator: std.mem.Allocator, node: jetzig.zmd.Node) ![]const u8 {
return try std.fmt.allocPrint(allocator,
\\<pre class="w-1/2 font-mono mt-4 ms-3 bg-gray-900 p-2 text-white"><code>{s}</code></pre>
, .{try jetzig.zmd.html.escape(allocator, node.content)});
}
pub fn link(allocator: std.mem.Allocator, node: jetzig.zmd.Node) ![]const u8 {
return try std.fmt.allocPrint(allocator,
\\<a class="underline decoration-sky-500" href="{0s}" title={1s}>{1s}</a>
, .{ node.href.?, node.title.? });
}
};
}
```

View File

@ -1,6 +1,7 @@
const std = @import("std");
pub const jetzig = @import("jetzig");
pub const routes = @import("routes").routes;
// Override default settings in `jetzig.config` here:
@ -30,6 +31,57 @@ pub const jetzig_options = struct {
// HTTP buffer. Must be large enough to store all headers. This should typically not be modified.
// pub const http_buffer_size: usize = std.math.pow(usize, 2, 16);
// Set custom fragments for rendering markdown templates. Any values will fall back to
// defaults provided by Zmd (https://github.com/bobf/zmd/blob/main/src/zmd/html.zig).
pub const markdown_fragments = struct {
pub const root = .{
"<div class='p-5'>",
"</div>",
};
pub const h1 = .{
"<h1 class='text-2xl mb-3 font-bold'>",
"</h1>",
};
pub const h2 = .{
"<h2 class='text-xl mb-3 font-bold'>",
"</h2>",
};
pub const h3 = .{
"<h3 class='text-lg mb-3 font-bold'>",
"</h3>",
};
pub const paragraph = .{
"<p class='p-3'>",
"</p>",
};
pub const code = .{
"<span class='font-mono bg-gray-900 p-2 text-white'>",
"</span>",
};
pub const unordered_list = .{
"<ul class='list-disc ms-8 leading-8'>",
"</ul>",
};
pub const ordered_list = .{
"<ul class='list-decimal ms-8 leading-8'>",
"</ul>",
};
pub fn block(allocator: std.mem.Allocator, node: jetzig.zmd.Node) ![]const u8 {
return try std.fmt.allocPrint(allocator,
\\<pre class="w-1/2 font-mono mt-4 ms-3 bg-gray-900 p-2 text-white"><code class="language-{?s}">{s}</code></pre>
, .{ node.meta, node.content });
}
pub fn link(allocator: std.mem.Allocator, node: jetzig.zmd.Node) ![]const u8 {
return try std.fmt.allocPrint(allocator,
\\<a class="underline decoration-sky-500" href="{0s}" title={1s}>{1s}</a>
, .{ node.href.?, node.title.? });
}
};
};
pub fn main() !void {

View File

@ -2,6 +2,7 @@ const std = @import("std");
const jetzig = @import("jetzig");
const routes = @import("routes").routes;
const zmpl = @import("zmpl");
const jetzig_options = @import("jetzig_app").jetzig_options;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
@ -101,10 +102,60 @@ fn writeContent(
std.debug.print("[jetzig] Compiled static route: {s}\n", .{json_path});
if (zmpl.find(route.template)) |template| {
var content: []const u8 = undefined;
defer allocator.free(content);
const html_content = try renderZmplTemplate(allocator, route, view) orelse
try renderMarkdown(allocator, route, view) orelse
null;
const html_path = try std.mem.concat(
allocator,
u8,
&[_][]const u8{ route.name, index_suffix, ".html" },
);
if (html_content) |content| {
defer allocator.free(html_path);
const html_file = try dir.createFile(html_path, .{ .truncate = true });
try html_file.writeAll(content);
defer html_file.close();
allocator.free(content);
std.debug.print("[jetzig] Compiled static route: {s}\n", .{html_path});
}
}
fn renderMarkdown(
allocator: std.mem.Allocator,
route: jetzig.views.Route,
view: jetzig.views.View,
) !?[]const u8 {
const fragments = if (@hasDecl(jetzig_options, "markdown_fragments"))
jetzig_options.markdown_fragments
else
null;
const content = try jetzig.markdown.render(allocator, &route, fragments) orelse return null;
if (route.layout) |layout_name| {
try view.data.addConst("jetzig_view", view.data.string(route.name));
try view.data.addConst("jetzig_action", view.data.string(@tagName(route.action)));
// TODO: Allow user to configure layouts directory other than src/app/views/layouts/
const prefixed_name = try std.mem.concat(allocator, u8, &[_][]const u8{ "layouts_", layout_name });
defer allocator.free(prefixed_name);
defer allocator.free(prefixed_name);
if (zmpl.find(prefixed_name)) |layout| {
view.data.content = .{ .data = content };
return try layout.render(view.data);
} else {
std.debug.print("Unknown layout: {s}\n", .{layout_name});
return content;
}
} else return null;
}
fn renderZmplTemplate(
allocator: std.mem.Allocator,
route: jetzig.views.Route,
view: jetzig.views.View,
) !?[]const u8 {
if (zmpl.find(route.template)) |template| {
try view.data.addConst("jetzig_view", view.data.string(route.name));
try view.data.addConst("jetzig_action", view.data.string(@tagName(route.action)));
@ -114,24 +165,13 @@ fn writeContent(
defer allocator.free(prefixed_name);
if (zmpl.find(prefixed_name)) |layout| {
content = try template.renderWithLayout(layout, view.data);
return try template.renderWithLayout(layout, view.data);
} else {
std.debug.print("Unknown layout: {s}\n", .{layout_name});
content = try allocator.dupe(u8, "");
return try allocator.dupe(u8, "");
}
} else {
content = try template.render(view.data);
return try template.render(view.data);
}
const html_path = try std.mem.concat(
allocator,
u8,
&[_][]const u8{ route.name, index_suffix, ".html" },
);
defer allocator.free(html_path);
const html_file = try dir.createFile(html_path, .{ .truncate = true });
try html_file.writeAll(content);
defer html_file.close();
std.debug.print("[jetzig] Compiled static route: {s}\n", .{html_path});
}
} else return null;
}

View File

@ -1,6 +1,7 @@
const std = @import("std");
pub const zmpl = @import("zmpl").zmpl;
pub const zmd = @import("zmd").zmd;
pub const http = @import("jetzig/http.zig");
pub const loggers = @import("jetzig/loggers.zig");
@ -10,6 +11,7 @@ pub const colors = @import("jetzig/colors.zig");
pub const middleware = @import("jetzig/middleware.zig");
pub const util = @import("jetzig/util.zig");
pub const types = @import("jetzig/types.zig");
pub const markdown = @import("jetzig/markdown.zig");
/// The primary interface for a Jetzig application. Create an `App` in your application's
/// `src/main.zig` and call `start` to launch the application.
@ -65,6 +67,9 @@ pub const config = struct {
/// modified.
pub const http_buffer_size: usize = std.math.pow(usize, 2, 16);
/// A struct of fragments to use when rendering Markdown templates.
pub const markdown_fragments = zmd.html.DefaultFragments;
/// Reconciles a configuration value from user-defined values and defaults provided by Jetzig.
pub fn get(T: type, comptime key: []const u8) T {
const self = @This();

View File

@ -108,11 +108,7 @@ fn renderResponse(self: *Self, request: *jetzig.http.Request) !void {
if (isUnhandledError(err)) return err;
const rendered = try self.renderInternalServerError(request, err);
request.response.content = rendered.content;
request.response.status_code = rendered.view.status_code;
request.response.content_type = "text/html";
setResponse(request, rendered, .{});
return;
};
@ -130,6 +126,16 @@ fn renderResponse(self: *Self, request: *jetzig.http.Request) !void {
}
}
fn setResponse(
request: *jetzig.http.Request,
rendered_view: RenderedView,
options: struct { content_type: []const u8 = "text/html" },
) void {
request.response.content = rendered_view.content;
request.response.status_code = rendered_view.view.status_code;
request.response.content_type = options.content_type;
}
fn renderStatic(resource: StaticResource, response: *jetzig.http.Response) !void {
response.status_code = .ok;
response.content = resource.content;
@ -146,12 +152,38 @@ fn renderHTML(
const rendered = self.renderView(matched_route, request, template) catch |err| {
if (isUnhandledError(err)) return err;
const rendered_error = try self.renderInternalServerError(request, err);
request.response.content = rendered_error.content;
request.response.status_code = rendered_error.view.status_code;
request.response.content_type = "text/html";
setResponse(request, rendered_error, .{});
return;
};
request.response.content = rendered.content;
setResponse(request, rendered, .{});
return;
} else if (try jetzig.markdown.render(request.allocator, matched_route, null)) |markdown_content| {
const rendered = self.renderView(matched_route, request, null) catch |err| {
if (isUnhandledError(err)) return err;
const rendered_error = try self.renderInternalServerError(request, err);
setResponse(request, rendered_error, .{});
return;
};
try addTemplateConstants(rendered.view, matched_route);
if (request.getLayout(matched_route)) |layout_name| {
// TODO: Allow user to configure layouts directory other than src/app/views/layouts/
const prefixed_name = try std.mem.concat(
self.allocator,
u8,
&[_][]const u8{ "layouts_", layout_name },
);
defer self.allocator.free(prefixed_name);
if (zmpl.manifest.find(prefixed_name)) |layout| {
rendered.view.data.content = .{ .data = markdown_content };
request.response.content = try layout.render(rendered.view.data);
} else {
try self.logger.WARN("Unknown layout: {s}", .{layout_name});
request.response.content = markdown_content;
}
}
request.response.status_code = rendered.view.status_code;
request.response.content_type = "text/html";
return;
@ -234,8 +266,7 @@ fn renderTemplateWithLayout(
view: jetzig.views.View,
route: *jetzig.views.Route,
) ![]const u8 {
try view.data.addConst("jetzig_view", view.data.string(route.view_name));
try view.data.addConst("jetzig_action", view.data.string(@tagName(route.action)));
try addTemplateConstants(view, route);
if (request.getLayout(route)) |layout_name| {
// TODO: Allow user to configure layouts directory other than src/app/views/layouts/
@ -251,6 +282,11 @@ fn renderTemplateWithLayout(
} else return try template.render(view.data);
}
fn addTemplateConstants(view: jetzig.views.View, route: *const jetzig.views.Route) !void {
try view.data.addConst("jetzig_view", view.data.string(route.view_name));
try view.data.addConst("jetzig_action", view.data.string(@tagName(route.action)));
}
fn isBadRequest(err: anyerror) bool {
return switch (err) {
error.JetzigBodyParseError, error.JetzigQueryParseError => true,

43
src/jetzig/markdown.zig Normal file
View File

@ -0,0 +1,43 @@
const std = @import("std");
const jetzig = @import("../jetzig.zig");
const Zmd = @import("zmd").Zmd;
pub fn render(
allocator: std.mem.Allocator,
route: *const jetzig.views.Route,
custom_fragments: ?type,
) !?[]const u8 {
const fragments = custom_fragments orelse jetzig.config.get(type, "markdown_fragments");
var path_buf = std.ArrayList([]const u8).init(allocator);
defer path_buf.deinit();
try path_buf.appendSlice(&[_][]const u8{ "src", "app", "views" });
var it = std.mem.splitScalar(u8, route.uri_path, '/');
while (it.next()) |segment| {
try path_buf.append(segment);
}
try path_buf.append(@tagName(route.action));
const base_path = try std.fs.path.join(allocator, path_buf.items);
defer allocator.free(base_path);
const full_path = try std.mem.concat(allocator, u8, &[_][]const u8{ base_path, ".md" });
defer allocator.free(full_path);
const stat = try std.fs.cwd().statFile(full_path);
const markdown_content = std.fs.cwd().readFileAlloc(allocator, full_path, stat.size) catch |err| {
switch (err) {
error.FileNotFound => return null,
else => return err,
}
};
var zmd = Zmd.init(allocator);
defer zmd.deinit();
try zmd.parse(markdown_content);
return try zmd.toHtml(fragments);
}