├── .gitignore
├── LICENSE
├── README.md
├── build.zig
├── build.zig.zon
└── src
├── Page.zig
├── PathTree.zig
├── build_file.zig
├── main.zig
├── processors.zig
├── resources
├── at-date.js
├── canvas.js
├── main.js
├── pygments.css
└── styles.css
├── testing.zig
├── tinymagic.zig
└── util.zig
/.gitignore:
--------------------------------------------------------------------------------
1 | zig-out/
2 | zig-cache/
3 | src/entities.zig
4 | .zigmod/
5 | .zigmod
6 | deps.zig
7 | public/
8 | *.o2w
9 | .zig-cache/
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 lun-4
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # obsidian2web
2 |
3 | my obsidian publish knockoff that generates (largely static) websites
4 |
5 | this idea came to be from using a notion page for a knowledge index and
6 | seeing absurdly poor performance come out of it. thought i'd make my own to
7 | get my fingers dirty in zig once again.
8 |
9 | you see it in action here:
10 | - https://l4.pm/wiki
11 | - https://l4.pm/vr/lifehax
12 |
13 | (note, do not name any folder inside your vault `public/`, it will break links,
14 | i learned this the hard way. one day i'll fix it.)
15 |
16 | # installation
17 |
18 | - get a recent build off https://ziglang.org/download/
19 | - tested with `0.13.0`
20 | - install libpcre in your system
21 |
22 | ```
23 | git clone https://github.com/lun-4/obsidian2web.git
24 | cd obsidian2web
25 | zig build
26 |
27 | # for production / release deployments
28 | zig build -Dtarget=x86_64-linux-musl -Dcpu=baseline -Doptimize=ReleaseSafe
29 | ```
30 |
31 | # usage
32 |
33 | you create an .o2w file with the following text format:
34 |
35 | ```
36 | vault /home/whatever/path/to/your/obsidian/vault
37 |
38 | # include directory1, from the perspective of the vault path
39 | # as in, /home/whatever/path/to/your/obsidian/vault/directory1 MUST exist
40 | include ./directory1
41 | include ./directory2
42 |
43 | # it also works with singular files
44 | include ./Some article.md
45 |
46 | # if you wish to include the entire vault, do this
47 | include .
48 | ```
49 |
50 | other directives you might add
51 |
52 | - `index ./path/to/some/article.md` to set the index page on your build
53 | - if not provided, a blank page is used
54 | - also operates relative to the vault path
55 | - `webroot /path/to/web/thing` to set the deployment location on the web
56 | - useful if you're deploying to a subfolder of your main domain
57 | - `strict_links yes` or `strict_links no` (default is `yes`)
58 | - either force all links to exist or let them fail silently (renders as `[[whatever]]` in the output html)
59 | - `project_footer yes` or `project_footer no` (default is `no`)
60 | - add a small reference to obsidian2web on all the page's footers.
61 | - `custom_css path/to/css/file`
62 | - use a differentt file for `styles.css` instead of the builtin one
63 | - `static_twitter_folder /path/to/folder/in/your/system`
64 | - when given, enables the `!twitter[...]` extension to your articles
65 | - requires [snscrape](https://github.com/JustAnotherArchivist/snscrape) installed in your machine
66 | - automatically downloads tweets referenced by that pattern into the folder, in jsonl format
67 | - it will download THE ENTIRE THREAD CHAIN, RECURSIVELY, BY DEFAULT. because archival is the primary purpose
68 | - it may take a while to download certain tweets, is what i mean.
69 | - you can ctrl-c the process and re-run, and it'll use the first tweet it has in the file.
70 | - remove the jsonl file in the folder if you wish to regenerate it
71 |
72 | build your vault like this
73 |
74 | ```
75 | ./zig-out/bin/obsidian2web path/to/build/file.o2w
76 | ```
77 |
78 | and now you have a `public/` in your current directory, ready for deploy!
79 |
--------------------------------------------------------------------------------
/build.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 |
3 | pub fn build(b: *std.Build) void {
4 | // Standard target options allows the person running `zig build` to choose
5 | // what target to build for. Here we do not override the defaults, which
6 | // means any target is allowed, and the default is native. Other options
7 | // for restricting supported target set are available.
8 | const target = b.standardTargetOptions(.{});
9 | const optimize = b.standardOptimizeOption(.{});
10 |
11 | const pcre_pkg = b.dependency("libpcre_zig", .{ .optimize = optimize, .target = target });
12 | const chrono_pkg = b.dependency("chrono", .{ .optimize = optimize, .target = target });
13 | const koino_pkg = b.dependency("koino", .{ .optimize = optimize, .target = target });
14 | const uuid_pkg = b.dependency("zig_uuid", .{ .optimize = optimize, .target = target });
15 |
16 | const Mod = struct { name: []const u8, mod: *std.Build.Module };
17 |
18 | const mod_deps = &[_]Mod{
19 | .{ .name = "libpcre", .mod = pcre_pkg.module("libpcre") },
20 | .{ .name = "chrono", .mod = chrono_pkg.module("chrono") },
21 | .{ .name = "koino", .mod = koino_pkg.module("koino") },
22 | .{ .name = "uuid", .mod = uuid_pkg.module("uuid") },
23 | };
24 |
25 | const exe = b.addExecutable(.{
26 | .name = "obsidian2web",
27 | .root_source_file = b.path("src/main.zig"),
28 | .target = target,
29 | .optimize = optimize,
30 | });
31 |
32 | for (mod_deps) |dep| {
33 | exe.root_module.addImport(dep.name, dep.mod);
34 | }
35 |
36 | b.installArtifact(exe);
37 |
38 | // This *creates* a Run step in the build graph, to be executed when another
39 | // step is evaluated that depends on it. The next line below will establish
40 | // such a dependency.
41 | const run_cmd = b.addRunArtifact(exe);
42 |
43 | // By making the run step depend on the install step, it will be run from the
44 | // installation directory rather than directly from within the cache directory.
45 | // This is not necessary, however, if the application depends on other installed
46 | // files, this ensures they will be present and in the expected location.
47 | run_cmd.step.dependOn(b.getInstallStep());
48 |
49 | // This allows the user to pass arguments to the application in the build
50 | // command itself, like this: `zig build run -- arg1 arg2 etc`
51 | if (b.args) |args| {
52 | run_cmd.addArgs(args);
53 | }
54 |
55 | // This creates a build step. It will be visible in the `zig build --help` menu,
56 | // and can be selected like this: `zig build run`
57 | // This will evaluate the `run` step rather than the default, which is "install".
58 | const run_step = b.step("run", "Run the app");
59 | run_step.dependOn(&run_cmd.step);
60 |
61 | // Creates a step for unit testing. This only builds the test executable
62 | // but does not run it.
63 | const unit_tests = b.addTest(.{
64 | .root_source_file = b.path("src/main.zig"),
65 | .target = target,
66 | .optimize = optimize,
67 | });
68 |
69 | for (mod_deps) |dep| {
70 | unit_tests.root_module.addImport(dep.name, dep.mod);
71 | }
72 |
73 | const run_unit_tests = b.addRunArtifact(unit_tests);
74 |
75 | // Similar to creating the run step earlier, this exposes a `test` step to
76 | // the `zig build --help` menu, providing a way for the user to request
77 | // running the unit tests.
78 | const test_step = b.step("test", "Run unit tests");
79 | test_step.dependOn(&run_unit_tests.step);
80 | }
81 |
--------------------------------------------------------------------------------
/build.zig.zon:
--------------------------------------------------------------------------------
1 | .{
2 | .name = .obsidian2web,
3 | .version = "0.1.1",
4 | .minimum_zig_version = "0.14.0",
5 | .fingerprint = 0xdb9a704608a0bb4a,
6 | .dependencies = .{
7 | .chrono = .{
8 | .url = "git+https://github.com/lun-4/chrono-zig?ref=zig-014#c9e3f92d22acc0587c8f7646febd59b8751ee8f8",
9 | .hash = "chrono-0.0.0-XcKmnqXLAwDJknlvxSX70NUFl1RJrhsqMUjZ1p1W3d-D",
10 | },
11 | .koino = .{
12 | .url = "git+https://github.com/lun-4/koino?ref=zig-014#dd285c54515156cabe9752502a55248987ca69df",
13 | .hash = "koino-0.1.0-S9LuWm2KAgAxXmxzqAK8g9xT-ulZOmvin_70B-QWOm6s",
14 | },
15 | .libpcre_zig = .{
16 | .url = "git+https://github.com/kivikakk/libpcre.zig#00b62bc8bea7da75ba61f56555ea6cbaf0dc4e26",
17 | .hash = "libpcre_zig-0.1.0-Dtf6Cb04AACKHZO38w4gDrY6to4lWHxOmW6htSqrR-f9",
18 | },
19 | .zig_uuid = .{
20 | .url = "git+https://github.com/lun-4/zig-uuid#78122167bbefeccb89afcb75fdd85400773aa58b",
21 | .hash = "zig_uuid-0.0.1-JWcRhNkcAACB_kHBB5tscJ9I8CCBHWr_V4pKTpAnYP9g",
22 | },
23 | },
24 | .paths = .{
25 | "build.zig",
26 | "build.zig.zon",
27 | "src",
28 | "LICENSE",
29 | "README.md",
30 | },
31 | }
32 |
--------------------------------------------------------------------------------
/src/Page.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 | const main = @import("main.zig");
3 | const util = @import("util.zig");
4 | const chrono = @import("chrono");
5 | const Context = main.Context;
6 | const OwnedStringList = main.OwnedStringList;
7 | const logger = std.log.scoped(.obsidian2web_page);
8 |
9 | page_type: PageType,
10 | ctx: *const Context,
11 | filesystem_path: []const u8,
12 | title: []const u8,
13 | attributes: PageAttributes,
14 |
15 | tags: ?OwnedStringList = null,
16 | titles: ?OwnedStringList = null,
17 | state: State = .{ .unbuilt = {} },
18 |
19 | maybe_first_image: ?[]const u8 = null,
20 |
21 | const Self = @This();
22 |
23 | pub const State = union(enum) {
24 | unbuilt: void,
25 | pre: []const u8,
26 | main: void,
27 | post: void,
28 | };
29 |
30 | pub const PageType = enum { md, canvas, asset };
31 |
32 | pub const PageAttributes = struct {
33 | ctime: i64,
34 |
35 | fn parseString(data: []const u8) []const u8 {
36 | return std.mem.trim(u8, data, "\"");
37 | }
38 |
39 | fn parseDate(date_string: []const u8) !i64 {
40 | var it = std.mem.splitSequence(u8, date_string, "-");
41 | const year = try std.fmt.parseInt(std.time.epoch.Year, it.next().?, 10);
42 | const month_int = try std.fmt.parseInt(u4, it.next().?, 10);
43 | const month = try std.meta.intToEnum(std.time.epoch.Month, month_int);
44 | const day = try std.fmt.parseInt(u5, it.next().?, 10);
45 |
46 | logger.warn("{d} - {} - {d}", .{ year, month, day });
47 | const ymd = chrono.date.YearMonthDay.fromNumbers(year, month.numeric(), day);
48 | logger.debug("ymd {}", .{ymd});
49 |
50 | return ymd.toDaysSinceUnixEpoch() * std.time.s_per_day;
51 | }
52 |
53 | pub fn fromFile(file: std.fs.File) !@This() {
54 | const stat = try file.stat();
55 | var self = @This(){
56 | .ctime = @as(i64, @intCast(@divTrunc(stat.ctime, std.time.ns_per_s))),
57 | };
58 | var first_bytes_buffer: [256]u8 = undefined;
59 |
60 | const bytes_read = try file.reader().read(&first_bytes_buffer);
61 | const first_bytes = first_bytes_buffer[0..bytes_read];
62 |
63 | logger.debug("first '{s}'", .{first_bytes});
64 | const first_plus_sign_idx = std.mem.indexOf(u8, first_bytes, "+++") orelse return self;
65 | const last_plus_sign_idx = std.mem.indexOfPos(u8, first_bytes, first_plus_sign_idx + 1, "+++") orelse return self;
66 |
67 | logger.debug("idx {d} {d}", .{ first_plus_sign_idx, last_plus_sign_idx });
68 | const attributes_text = first_bytes[first_plus_sign_idx + 3 .. last_plus_sign_idx];
69 | var lines = std.mem.splitSequence(u8, attributes_text, "\n");
70 | logger.debug("text '{s}'", .{attributes_text});
71 | while (lines.next()) |line| {
72 | if (line.len == 0) continue;
73 | var key_value_iterator = std.mem.splitSequence(u8, line, "=");
74 | const key = std.mem.trim(u8, key_value_iterator.next() orelse continue, " ");
75 | const value = std.mem.trim(u8, key_value_iterator.next() orelse {
76 | logger.err("key '{s}' does not have value", .{key});
77 | return error.InvalidAttribute;
78 | }, " ");
79 |
80 | if (std.mem.eql(u8, key, "date")) {
81 | const date_string = parseString(value);
82 | self.ctime = try parseDate(date_string);
83 | }
84 | }
85 | return self;
86 | }
87 |
88 | test "fallbacks to system ctime" {
89 | const This = @This();
90 | std.testing.log_level = .debug;
91 |
92 | var tmp_dir = std.testing.tmpDir(.{});
93 | defer tmp_dir.cleanup();
94 |
95 | const current_time = std.time.timestamp();
96 | var file = try tmp_dir.dir.createFile("test.md", .{ .read = true });
97 | defer file.close();
98 |
99 | const attrs = try This.fromFile(file);
100 |
101 | const delta = @abs(attrs.ctime - current_time);
102 | logger.debug("curtime = {d}", .{current_time});
103 | logger.debug("ctime = {d}", .{attrs.ctime});
104 | logger.debug("delta = {d}", .{delta});
105 | try std.testing.expect(delta < 10);
106 |
107 | const date_from_attrs = (std.time.epoch.EpochSeconds{
108 | .secs = @as(u64, @intCast(attrs.ctime)),
109 | }).getEpochDay().calculateYearDay();
110 | const date_from_curtime = (std.time.epoch.EpochSeconds{
111 | .secs = @as(u64, @intCast(current_time)),
112 | }).getEpochDay().calculateYearDay();
113 |
114 | try std.testing.expectEqual(date_from_curtime.day, date_from_attrs.day);
115 | try std.testing.expectEqual(date_from_curtime.year, date_from_attrs.year);
116 |
117 | const month_from_curtime = date_from_curtime.calculateMonthDay();
118 |
119 | const naive_dt = chrono.date.YearMonthDay.fromDaysSinceUnixEpoch(@truncate(@divTrunc(attrs.ctime, std.time.s_per_day)));
120 | try std.testing.expectEqual(date_from_curtime.year, @as(u16, @intCast(naive_dt.year)));
121 | try std.testing.expectEqual(month_from_curtime.month.numeric(), naive_dt.month.number());
122 | try std.testing.expectEqual(month_from_curtime.day_index + 1, naive_dt.day);
123 | }
124 |
125 | test "parses ctime" {
126 | const This = @This();
127 |
128 | var tmp_dir = std.testing.tmpDir(.{});
129 | defer tmp_dir.cleanup();
130 |
131 | var file = try tmp_dir.dir.createFile("test.md", .{ .read = true });
132 | defer file.close();
133 |
134 | try file.writeAll(
135 | \\+++
136 | \\date="2023-03-04"
137 | \\+++
138 | );
139 | try file.seekTo(0);
140 | const attrs = try This.fromFile(file);
141 | const naive_dt = chrono.date.YearMonthDay.fromDaysSinceUnixEpoch(@truncate(@divTrunc(attrs.ctime, std.time.s_per_day)));
142 |
143 | try std.testing.expectEqual(@as(i23, 2023), naive_dt.year);
144 | try std.testing.expectEqual(@as(u4, 3), naive_dt.month.number());
145 | try std.testing.expectEqual(@as(u5, 4), naive_dt.day);
146 |
147 | const date_from_attrs = (std.time.epoch.EpochSeconds{
148 | .secs = @as(u64, @intCast(attrs.ctime)),
149 | }).getEpochDay().calculateYearDay();
150 |
151 | const month_from_attrs = date_from_attrs.calculateMonthDay();
152 |
153 | try std.testing.expectEqual(@as(i19, 2023), date_from_attrs.year);
154 | try std.testing.expectEqual(@as(i19, 3), month_from_attrs.month.numeric());
155 | try std.testing.expectEqual(@as(i19, 4), month_from_attrs.day_index + 1);
156 | }
157 | };
158 |
159 | pub fn relativePathWithoutExtension(self: Self) []const u8 {
160 | return switch (self.page_type) {
161 | .asset => unreachable,
162 | .md => self.filesystem_path[0 .. self.filesystem_path.len - 3],
163 | .canvas => self.filesystem_path[0 .. self.filesystem_path.len - 7],
164 | };
165 | }
166 |
167 | /// assumes given path is a ".md" file.
168 | pub fn fromPath(ctx: *const Context, fspath: []const u8) !Self {
169 | const title_offset: usize =
170 | if (std.mem.endsWith(u8, fspath, ".md")) 3 else if (std.mem.endsWith(u8, fspath, ".canvas")) 7 else return error.InvalidPath;
171 | const page_type: PageType =
172 | if (std.mem.endsWith(u8, fspath, ".md")) .md else if (std.mem.endsWith(u8, fspath, ".canvas")) .canvas else unreachable;
173 |
174 | const title_raw = std.fs.path.basename(fspath);
175 | const title = title_raw[0 .. title_raw.len - title_offset];
176 | logger.info("create page with title '{s}' @ {s}", .{ title, fspath });
177 |
178 | var file = try std.fs.cwd().openFile(fspath, .{});
179 | defer file.close();
180 | const attributes = try PageAttributes.fromFile(file);
181 |
182 | return Self{
183 | .page_type = page_type,
184 | .ctx = ctx,
185 | .filesystem_path = fspath,
186 | .attributes = attributes,
187 | .title = title,
188 | };
189 | }
190 |
191 | pub fn fromAssetPath(ctx: *const Context, fspath: []const u8) !Self {
192 | logger.info("create asset with fspath {s}", .{fspath});
193 |
194 | var file = try std.fs.cwd().openFile(fspath, .{});
195 | defer file.close();
196 | const attributes = try PageAttributes.fromFile(file);
197 |
198 | return Self{
199 | .page_type = .asset,
200 | .ctx = ctx,
201 | .filesystem_path = fspath,
202 | .attributes = attributes,
203 | .title = "",
204 | };
205 | }
206 |
207 | pub fn deinit(self: Self) void {
208 | if (self.tags) |tags| {
209 | for (tags.items) |tag| self.ctx.allocator.free(tag);
210 | tags.deinit();
211 | }
212 | if (self.titles) |titles| {
213 | for (titles.items) |title| self.ctx.allocator.free(title);
214 | titles.deinit();
215 | }
216 | if (self.maybe_first_image) |image| self.ctx.allocator.free(image);
217 | }
218 |
219 | pub fn format(
220 | self: Self,
221 | comptime fmt: []const u8,
222 | options: std.fmt.FormatOptions,
223 | writer: anytype,
224 | ) !void {
225 | _ = fmt;
226 | _ = options;
227 | return writer.print("Page", .{self.filesystem_path});
228 | }
229 |
230 | pub fn relativePath(self: Self) []const u8 {
231 | const stripped = util.stripLeft(self.filesystem_path, self.ctx.build_file.vault_path);
232 | // if you triggered this assertion, its likely vault path ended with a slash,
233 | // removing it should work.
234 | std.debug.assert(stripped[0] == '/'); // TODO better path handling code
235 | const relative_fspath = stripped[1..];
236 | std.debug.assert(relative_fspath[0] != '/'); // must be relative afterwards
237 | return relative_fspath;
238 | }
239 |
240 | pub fn fetchHtmlPath(self: Self, allocator: std.mem.Allocator) ![]const u8 {
241 | // output_path = relative_fspath with ".md" replaced to ".html"
242 |
243 | const raw_output_path = try std.fs.path.resolve(
244 | allocator,
245 | &[_][]const u8{ "public", self.relativePath() },
246 | );
247 | defer allocator.free(raw_output_path);
248 |
249 | switch (self.page_type) {
250 | .md => return try util.replaceStrings(
251 | allocator,
252 | raw_output_path,
253 | ".md",
254 | ".html",
255 | ),
256 | .canvas => return try util.replaceStrings(
257 | allocator,
258 | raw_output_path,
259 | ".canvas",
260 | ".html",
261 | ),
262 | .asset => unreachable,
263 | }
264 | }
265 |
266 | pub fn fetchWebPath(
267 | self: Self,
268 | allocator: std.mem.Allocator,
269 | ) ![]const u8 {
270 | const output_path = try self.fetchHtmlPath(allocator);
271 | defer allocator.free(output_path);
272 |
273 | // to generate web_path, we need to:
274 | // - take html_path
275 | // - remove public/
276 | // - replace std.fs.path.sep to '/'
277 | // - Uri.escapeString
278 |
279 | const trimmed_output_path = util.stripLeft(
280 | output_path,
281 | "public" ++ std.fs.path.sep_str,
282 | );
283 |
284 | const trimmed_output_path_2 = try util.replaceStrings(
285 | allocator,
286 | trimmed_output_path,
287 | std.fs.path.sep_str,
288 | "/",
289 | );
290 | defer allocator.free(trimmed_output_path_2);
291 | const web_path = try customEscapeString(allocator, trimmed_output_path_2);
292 | return web_path;
293 | }
294 |
295 | /// unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
296 | fn isUnreserved(c: u8) bool {
297 | return switch (c) {
298 | 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
299 | else => false,
300 | };
301 | }
302 |
303 | fn isAuthoritySeparator(c: u8) bool {
304 | return switch (c) {
305 | '/', '?', '#' => true,
306 | else => false,
307 | };
308 | }
309 |
310 | // stolen from std.Uri
311 | fn customEscapeString(allocator: std.mem.Allocator, input: []const u8) error{OutOfMemory}![]const u8 {
312 | var outsize: usize = 0;
313 | for (input) |c| {
314 | outsize += if (isUnreserved(c) or c == '/') @as(usize, 1) else 3;
315 | }
316 | var output = try allocator.alloc(u8, outsize);
317 | var outptr: usize = 0;
318 |
319 | for (input) |c| {
320 | if (isUnreserved(c) or c == '/') {
321 | output[outptr] = c;
322 | outptr += 1;
323 | } else {
324 | var buf: [2]u8 = undefined;
325 | _ = std.fmt.bufPrint(&buf, "{X:0>2}", .{c}) catch unreachable;
326 |
327 | output[outptr + 0] = '%';
328 | output[outptr + 1] = buf[0];
329 | output[outptr + 2] = buf[1];
330 | outptr += 3;
331 | }
332 | }
333 | return output;
334 | }
335 |
336 | pub fn fetchPreview(self: Self, buffer: []u8) ![]const u8 {
337 | var page_fd = try std.fs.cwd().openFile(
338 | self.filesystem_path,
339 | .{ .mode = .read_only },
340 | );
341 | defer page_fd.close();
342 | const page_preview_text_read_bytes = try page_fd.read(buffer);
343 | var i: usize = 0;
344 | var out_cursor: usize = 0;
345 |
346 | // snip dangerous characters from preview
347 | while (i < page_preview_text_read_bytes) : (i += 1) {
348 | //std.debug.print("i {d}, out_cursor {d}, cur {s}\n", .{ i, out_cursor, &[_]u8{buffer[i]} });
349 |
350 | // [[ or ]] become [ or ]
351 | const current_char = buffer[i];
352 | var next_char_v: ?u8 = null;
353 | if (i + 1 < page_preview_text_read_bytes) {
354 | next_char_v = buffer[i + 1];
355 | }
356 | const next_char = next_char_v;
357 | if (current_char == '[' and next_char == '[') {
358 | buffer[out_cursor] = '[';
359 | out_cursor += 1;
360 | i += 1; // skip next [
361 | } else if (current_char == ']' and next_char == ']') {
362 | buffer[out_cursor] = ']';
363 | out_cursor += 1;
364 | i += 1; // skip next ]
365 |
366 | } else if (current_char == '\n') {
367 | buffer[out_cursor] = ' ';
368 | out_cursor += 1;
369 | } else {
370 | buffer[out_cursor] = current_char;
371 | out_cursor += 1;
372 | }
373 | }
374 | return buffer[0..out_cursor];
375 | }
376 |
377 | /// Returns amount of seconds representing the age of the given page (determined via ctime)
378 | pub fn age(self: Self) usize {
379 | const now = std.time.timestamp();
380 | return @intCast(now - self.attributes.ctime);
381 | }
382 |
--------------------------------------------------------------------------------
/src/PathTree.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 | const main = @import("root");
3 | const util = @import("util.zig");
4 | const Context = main.Context;
5 | const OwnedStringList = main.OwnedStringList;
6 | const logger = std.log.scoped(.obsidian2web_page);
7 |
8 | allocator: std.mem.Allocator,
9 | root: PageFolder,
10 |
11 | pub const PageFile = union(enum) {
12 | dir: PageFolder,
13 | file: []const u8,
14 | };
15 |
16 | pub const PageFolder = std.StringHashMap(PageFile);
17 |
18 | const Self = @This();
19 |
20 | pub fn init(allocator: std.mem.Allocator) Self {
21 | return Self{
22 | .allocator = allocator,
23 | .root = PageFolder.init(allocator),
24 | };
25 | }
26 |
27 | /// recursively deinitialize a PageFolder
28 | fn deinitPageFolder(folder: *PageFolder) void {
29 | var folder_it = folder.iterator();
30 | while (folder_it.next()) |entry| {
31 | const child = entry.value_ptr;
32 | switch (child.*) {
33 | .dir => |*child_folder| deinitPageFolder(child_folder),
34 | .file => {},
35 | }
36 | }
37 | folder.deinit();
38 | }
39 |
40 | pub fn deinit(self: *Self) void {
41 | deinitPageFolder(&self.root);
42 | }
43 |
44 | /// Recursively walk from a PageFolder to another PageFolder, using `to` as
45 | /// a guide.
46 | pub fn walkToDir(from: PageFolder, to: []const u8) PageFolder {
47 | var it = std.mem.splitSequence(u8, to, std.fs.path.sep_str);
48 | const component = it.next().?;
49 | _ = it.next() orelse return from;
50 | return walkToDir(from.get(component).?.dir, to[component.len + 1 ..]);
51 | }
52 |
53 | pub fn addPath(self: *Self, fspath: []const u8) !void {
54 | const total_seps = std.mem.count(u8, fspath, std.fs.path.sep_str);
55 | var path_it = std.mem.splitSequence(u8, fspath, std.fs.path.sep_str);
56 |
57 | var current_page: ?*PageFolder = &self.root;
58 | var idx: usize = 0;
59 | while (true) : (idx += 1) {
60 | const maybe_path_component = path_it.next();
61 | if (maybe_path_component == null) break;
62 | const path_component = maybe_path_component.?;
63 |
64 | if (current_page.?.getPtr(path_component)) |child_page| {
65 | current_page = &child_page.dir;
66 | } else {
67 |
68 | // if last component, create file (and set current_page to null), else, create folder
69 | if (idx == total_seps) {
70 | try current_page.?.put(path_component, .{ .file = fspath });
71 | } else {
72 | try current_page.?.put(path_component, .{ .dir = PageFolder.init(self.allocator) });
73 | current_page = ¤t_page.?.getPtr(path_component).?.dir;
74 | }
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/build_file.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 | const root = @import("main.zig");
3 |
4 | const StringList = root.SliceList;
5 |
6 | pub const ConfigDirectives = struct {
7 | strict_links: bool = true,
8 | index: ?[]const u8 = null,
9 | webroot: []const u8 = "",
10 | project_footer: bool = false,
11 | code_highlight: bool = false,
12 | custom_css: ?[]const u8 = null,
13 | static_twitter_folder: ?[]const u8 = null,
14 | rss: ?[]const u8 = null,
15 | rss_title: ?[]const u8 = null,
16 | rss_description: ?[]const u8 = null,
17 | };
18 |
19 | fn parseBool(string: []const u8) bool {
20 | if (std.mem.eql(u8, "yes", string)) return true;
21 | if (std.mem.eql(u8, "no", string)) return false;
22 | return false;
23 | }
24 |
25 | pub const BuildFile = struct {
26 | allocator: std.mem.Allocator,
27 | vault_path: []const u8,
28 | includes: StringList,
29 | config: ConfigDirectives,
30 |
31 | const Self = @This();
32 |
33 | pub fn parse(allocator: std.mem.Allocator, input_data: []const u8) !Self {
34 | var includes = StringList.init(allocator);
35 | errdefer includes.deinit();
36 | var file_lines_it = std.mem.splitSequence(u8, input_data, "\n");
37 |
38 | var config = ConfigDirectives{};
39 |
40 | var vault_path: ?[]const u8 = null;
41 | while (file_lines_it.next()) |line| {
42 | if (line.len == 0) continue;
43 | if (line[0] == '#') continue;
44 | const first_space_index =
45 | std.mem.indexOf(u8, line, " ") orelse return error.ParseError;
46 |
47 | const directive = std.mem.trim(u8, line[0..first_space_index], "\n");
48 | const value = line[first_space_index + 1 ..];
49 | if (std.mem.eql(u8, "vault", directive)) {
50 | vault_path = value;
51 | } else if (std.mem.eql(u8, "include", directive)) {
52 | try includes.append(value);
53 | } else if (std.mem.eql(u8, "index", directive)) {
54 | config.index = value;
55 | } else if (std.mem.eql(u8, "webroot", directive)) {
56 | config.webroot = value;
57 | } else if (std.mem.eql(u8, "strict_links", directive)) {
58 | config.strict_links = parseBool(value);
59 | } else if (std.mem.eql(u8, "project_footer", directive)) {
60 | config.project_footer = parseBool(value);
61 | } else if (std.mem.eql(u8, "code_highlight", directive)) {
62 | config.code_highlight = parseBool(value);
63 | } else if (std.mem.eql(u8, "rss", directive)) {
64 | config.rss = value;
65 | } else if (std.mem.eql(u8, "rss_title", directive)) {
66 | config.rss_title = value;
67 | } else if (std.mem.eql(u8, "rss_description", directive)) {
68 | config.rss_description = value;
69 | } else if (std.mem.eql(u8, "custom_css", directive)) {
70 | config.custom_css = value;
71 | } else if (std.mem.eql(u8, "static_twitter_folder", directive)) {
72 | config.static_twitter_folder = value;
73 | } else {
74 | std.log.err("unknown directive '{s}'", .{directive});
75 | return error.UnknownDirective;
76 | }
77 | }
78 |
79 | return Self{
80 | .allocator = allocator,
81 | .vault_path = vault_path orelse return error.VaultPathRequired,
82 | .includes = includes,
83 | .config = config,
84 | };
85 | }
86 |
87 | pub fn deinit(self: Self) void {
88 | self.includes.deinit();
89 | }
90 | };
91 |
92 | test "build file works" {
93 | const test_file =
94 | \\vault /home/test/vault
95 | \\include Folder1/
96 | \\include ./
97 | \\include Folder2/
98 | \\include TestFile.md
99 | \\index Abcdef
100 | ;
101 |
102 | var build_file = try BuildFile.parse(std.testing.allocator, test_file);
103 | defer build_file.deinit();
104 | try std.testing.expectEqualStrings("/home/test/vault", build_file.vault_path);
105 | try std.testing.expectEqualStrings("Abcdef", build_file.config.index orelse return error.UnexpectedNull);
106 | try std.testing.expectEqual(@as(usize, 4), build_file.includes.items.len);
107 | }
108 |
--------------------------------------------------------------------------------
/src/main.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 | const koino = @import("koino");
3 | const libpcre = @import("libpcre");
4 |
5 | pub const OwnedStringList = std.ArrayList([]const u8);
6 | pub const BuildFile = @import("build_file.zig").BuildFile;
7 | const processors = @import("processors.zig");
8 | const util = @import("util.zig");
9 | const uuid = @import("uuid");
10 |
11 | pub const std_options = std.Options{
12 | .log_level = .debug,
13 | };
14 |
15 | const logger = std.log.scoped(.obsidian2web);
16 |
17 | const Page = @import("Page.zig");
18 | const PageMap = std.StringHashMap(Page);
19 |
20 | // article on path a/b/c/d/e.md is mapped as "e" in this title map.
21 | const TitleMap = std.StringHashMap([]const u8);
22 |
23 | const PathTree = @import("PathTree.zig");
24 | pub const StringBuffer = std.ArrayList(u8);
25 | pub const SliceList = std.ArrayList([]const u8);
26 |
27 | const TreeGeneratorContext = struct {
28 | current_folder: ?PathTree.PageFolder = null,
29 | root_folder: ?PathTree.PageFolder = null,
30 | indentation_level: usize = 0,
31 | };
32 |
33 | fn printHashMap(map: anytype) void {
34 | var it = map.iterator();
35 | while (it.next()) |entry| {
36 | logger.debug(
37 | "key={s} value={any}",
38 | .{ entry.key_ptr.*, entry.value_ptr.* },
39 | );
40 | }
41 | }
42 |
43 | fn writePageTree(
44 | writer: anytype,
45 | ctx: *const Context,
46 | tree_context: TreeGeneratorContext,
47 | /// Set this if generating a tree in a specific page.
48 | ///
49 | /// Set to null if on index page.
50 | generating_tree_for: ?*const Page,
51 | ) !void {
52 | const root_folder =
53 | tree_context.root_folder orelse
54 | PathTree.walkToDir(ctx.tree.root, ctx.build_file.vault_path);
55 | const current_folder =
56 | tree_context.current_folder orelse root_folder;
57 |
58 | // step 1: find all the folders at this level.
59 |
60 | var folders = SliceList.init(ctx.allocator);
61 | defer folders.deinit();
62 |
63 | var files = SliceList.init(ctx.allocator);
64 | defer files.deinit();
65 |
66 | {
67 | var folder_iterator = current_folder.iterator();
68 |
69 | while (folder_iterator.next()) |entry| {
70 | switch (entry.value_ptr.*) {
71 | .dir => try folders.append(entry.key_ptr.*),
72 | .file => try files.append(entry.key_ptr.*),
73 | }
74 | }
75 |
76 | std.sort.insertion([]const u8, folders.items, {}, util.lexicographicalCompare);
77 | std.sort.insertion([]const u8, files.items, {}, util.lexicographicalCompare);
78 | }
79 |
80 | // draw folders first (they recurse)
81 | // then draw files second
82 |
83 | for (folders.items) |folder_name| {
84 | try writer.print("", .{});
85 |
86 | const child_folder = current_folder.getPtr(folder_name).?.dir;
87 | try writer.print(
88 | "{s}
\n",
89 | .{util.unsafeHTML(folder_name)},
90 | );
91 |
92 | const child_context = TreeGeneratorContext{
93 | .indentation_level = tree_context.indentation_level + 1,
94 | .current_folder = child_folder,
95 | };
96 |
97 | try writePageTree(writer, ctx, child_context, generating_tree_for);
98 | try writer.print(" \n", .{});
99 | }
100 |
101 | const for_web_path = if (generating_tree_for) |current_page|
102 | try current_page.fetchWebPath(ctx.allocator)
103 | else
104 | null;
105 | defer if (for_web_path) |path| ctx.allocator.free(path);
106 |
107 | try writer.print("\n", .{});
108 | for (files.items) |file_name| {
109 | const file_path = current_folder.get(file_name).?.file;
110 | const page = ctx.pages.get(file_path).?;
111 |
112 | const page_web_path = try page.fetchWebPath(ctx.allocator);
113 | defer ctx.allocator.free(page_web_path);
114 |
115 | const current_attr = if (for_web_path != null and std.mem.eql(u8, for_web_path.?, page_web_path))
116 | "aria-current=\"page\" "
117 | else
118 | " ";
119 |
120 | try writer.print(
121 | "- {s}
\n",
122 | .{
123 | current_attr,
124 | ctx.webPath("/{s}", .{page_web_path}),
125 | util.unsafeHTML(page.title),
126 | },
127 | );
128 | }
129 | try writer.print("
\n", .{});
130 | }
131 |
132 | const testing = @import("testing.zig");
133 | test "page tree sets aria-current" {
134 | const TEST_DATA = .{
135 | .{ "awoogapage", "", "awoogapage" },
136 | };
137 |
138 | inline for (TEST_DATA) |test_entry| {
139 | const title = test_entry.@"0";
140 | const input = test_entry.@"1";
141 | const expected_output = test_entry.@"2";
142 |
143 | var test_ctx = testing.TestContext.init();
144 | defer test_ctx.deinit();
145 |
146 | try testing.runTestWithSingleEntry(&test_ctx, title, input, expected_output);
147 | }
148 | }
149 |
150 | const FOOTER =
151 | \\
154 | ;
155 |
156 | pub const ArenaHolder = struct {
157 | paths: std.heap.ArenaAllocator,
158 | const Self = @This();
159 |
160 | pub fn init(allocator: std.mem.Allocator) Self {
161 | return Self{
162 | .paths = std.heap.ArenaAllocator.init(allocator),
163 | };
164 | }
165 |
166 | pub fn deinit(self: Self) void {
167 | self.paths.deinit();
168 | }
169 | };
170 |
171 | pub const Context = struct {
172 | allocator: std.mem.Allocator,
173 | build_file: BuildFile,
174 | vault_dir: std.fs.Dir,
175 | arenas: ArenaHolder,
176 | pages: PageMap,
177 | assets: PageMap,
178 | fspaths: OwnedStringList,
179 | tree: PathTree,
180 |
181 | const Self = @This();
182 |
183 | pub fn init(
184 | allocator: std.mem.Allocator,
185 | build_file: BuildFile,
186 | vault_dir: std.fs.Dir,
187 | ) Self {
188 | return Self{
189 | .allocator = allocator,
190 | .build_file = build_file,
191 | .vault_dir = vault_dir,
192 | .arenas = ArenaHolder.init(allocator),
193 | .pages = PageMap.init(allocator),
194 | .assets = PageMap.init(allocator),
195 | .tree = PathTree.init(allocator),
196 | .fspaths = OwnedStringList.init(allocator),
197 | };
198 | }
199 |
200 | pub fn deinit(self: *Self) void {
201 | self.arenas.deinit();
202 | {
203 | var it = self.pages.iterator();
204 | while (it.next()) |entry| entry.value_ptr.deinit();
205 | }
206 | {
207 | var it = self.assets.iterator();
208 | while (it.next()) |entry| entry.value_ptr.deinit();
209 | }
210 | self.pages.deinit();
211 | self.assets.deinit();
212 | self.tree.deinit();
213 | self.fspaths.deinit();
214 | }
215 |
216 | pub fn pathAllocator(self: *Self) std.mem.Allocator {
217 | return self.arenas.paths.allocator();
218 | }
219 |
220 | pub fn addPage(self: *Self, path: []const u8) !void {
221 | const owned_fspath = try self.pathAllocator().dupe(u8, path);
222 |
223 | // if not a page that should be rendered, add it to only titlemap
224 | const must_render =
225 | std.mem.endsWith(u8, path, ".md") or std.mem.endsWith(u8, path, ".canvas");
226 |
227 | if (must_render) {
228 | const pages_result = try self.pages.getOrPut(owned_fspath);
229 | if (!pages_result.found_existing) {
230 | const page = try Page.fromPath(self, owned_fspath);
231 | pages_result.value_ptr.* = page;
232 | try self.tree.addPath(page.filesystem_path);
233 | }
234 | } else {
235 | // don't render, instead create as an Asset (a variant of Page)
236 | const assets_result = try self.assets.getOrPut(owned_fspath);
237 | if (!assets_result.found_existing) {
238 | const asset = try Page.fromAssetPath(self, owned_fspath);
239 | assets_result.value_ptr.* = asset;
240 | }
241 | }
242 | }
243 |
244 | pub fn pageFromPath(self: Self, path: []const u8) ?Page {
245 | return self.pages.get(path);
246 | }
247 |
248 | pub fn webPath(
249 | self: Self,
250 | comptime fmt: []const u8,
251 | args: anytype,
252 | ) util.WebPathPrinter(@TypeOf(args), fmt) {
253 | comptime std.debug.assert(fmt[0] == '/'); // must be path
254 | return util.WebPathPrinter(@TypeOf(args), fmt){
255 | .ctx = self,
256 | .args = args,
257 | };
258 | }
259 | };
260 |
261 | pub const ByteList = std.ArrayList(u8);
262 |
263 | // insert into PageTree from the given include paths
264 | pub fn iterateVaultPath(ctx: *Context) !void {
265 | for (ctx.build_file.includes.items) |relative_include_path| {
266 | const absolute_include_path = try std.fs.path.resolve(
267 | ctx.allocator,
268 | &[_][]const u8{ ctx.build_file.vault_path, relative_include_path },
269 | );
270 | defer ctx.allocator.free(absolute_include_path);
271 |
272 | logger.info("including given path: '{s}'", .{absolute_include_path});
273 |
274 | // attempt to openDir first, if it fails assume file
275 | var included_dir = std.fs.cwd().openDir(
276 | absolute_include_path,
277 | .{ .iterate = true },
278 | ) catch |err| switch (err) {
279 | error.NotDir => {
280 | try ctx.addPage(absolute_include_path);
281 | continue;
282 | },
283 |
284 | else => return err,
285 | };
286 | defer included_dir.close();
287 |
288 | // Walker already recurses into all child paths
289 |
290 | var walker = try included_dir.walk(ctx.allocator);
291 | defer walker.deinit();
292 |
293 | while (try walker.next()) |entry| {
294 | switch (entry.kind) {
295 | .file => {
296 | const absolute_file_path = try std.fs.path.join(
297 | ctx.allocator,
298 | &[_][]const u8{ absolute_include_path, entry.path },
299 | );
300 | defer ctx.allocator.free(absolute_file_path);
301 | try ctx.addPage(absolute_file_path);
302 | },
303 |
304 | else => {},
305 | }
306 | }
307 | }
308 | }
309 |
310 | pub fn main() anyerror!void {
311 | var allocator_instance = std.heap.GeneralPurposeAllocator(.{}){};
312 | defer _ = allocator_instance.deinit();
313 |
314 | const allocator = allocator_instance.allocator();
315 |
316 | var args_it = std.process.args();
317 | defer args_it.deinit();
318 |
319 | _ = args_it.skip();
320 | const build_file_path = args_it.next() orelse {
321 | logger.err("pass path to build file as 1st argument", .{});
322 | return error.InvalidArguments;
323 | };
324 |
325 | var build_file_data_buffer: [8192]u8 = undefined;
326 | const build_file_data = blk: {
327 | const build_file_fd = try std.fs.cwd().openFile(
328 | build_file_path,
329 | .{ .mode = .read_only },
330 | );
331 | defer build_file_fd.close();
332 |
333 | const build_file_data_count = try build_file_fd.read(
334 | &build_file_data_buffer,
335 | );
336 | break :blk build_file_data_buffer[0..build_file_data_count];
337 | };
338 |
339 | var build_file = try BuildFile.parse(allocator, build_file_data);
340 | defer build_file.deinit();
341 |
342 | var vault_dir = try std.fs.cwd().openDir(build_file.vault_path, .{ .iterate = true });
343 | defer vault_dir.close();
344 |
345 | var ctx = Context.init(allocator, build_file, vault_dir);
346 | defer ctx.deinit();
347 |
348 | // main pipeline starts here
349 | {
350 | try iterateVaultPath(&ctx);
351 | try std.fs.cwd().makePath("public/");
352 | try createStaticResources(ctx);
353 |
354 | // for each page
355 | // - pass 1: run pre processors
356 | // - pass 2: turn page markdown into html (koino)
357 | // - pass 3: run post processors
358 |
359 | var pre_processors = try initProcessors(PreProcessors);
360 | defer deinitProcessors(pre_processors);
361 |
362 | var post_processors = try initProcessors(PostProcessors);
363 | defer deinitProcessors(post_processors);
364 |
365 | var pages_it = ctx.pages.iterator();
366 | while (pages_it.next()) |entry| {
367 | try runProcessors(&ctx, &pre_processors, entry.value_ptr, .{ .pre = true });
368 | try mainPass(&ctx, entry.value_ptr);
369 | try runProcessors(&ctx, &post_processors, entry.value_ptr, .{});
370 | }
371 | }
372 |
373 | try std.fs.cwd().makePath("public/assets");
374 | var assets_it = ctx.assets.iterator();
375 | while (assets_it.next()) |entry| {
376 | const asset: Page = entry.value_ptr.*;
377 | var output_path_buffer: [std.posix.PATH_MAX]u8 = undefined;
378 | const output_path = try std.fmt.bufPrint(
379 | &output_path_buffer,
380 | "public/assets/{s}",
381 | .{asset.relativePath()},
382 | );
383 |
384 | const leading_path_to_file = std.fs.path.dirname(output_path).?;
385 | logger.info("mkdir asset {s}", .{leading_path_to_file});
386 | try std.fs.cwd().makePath(leading_path_to_file);
387 |
388 | logger.info("cp asset {s} -> {s}", .{ asset.filesystem_path, output_path });
389 | try std.fs.cwd().copyFile(asset.filesystem_path, std.fs.cwd(), output_path, .{});
390 | }
391 |
392 | // end processors are for features that only work once *all* pages
393 | // were successfully processed (like tag pages).
394 | {
395 | var end_processors = try initProcessors(EndProcessors);
396 | defer deinitProcessors(end_processors);
397 |
398 | var pages_it = ctx.pages.iterator();
399 | logger.info("running end processors", .{});
400 | while (pages_it.next()) |entry| {
401 | try runProcessors(&ctx, &end_processors, entry.value_ptr, .{ .end = true });
402 | }
403 | }
404 |
405 | // generate index page
406 | try generateIndexPage(ctx);
407 | try generateTagPages(ctx);
408 | if (ctx.build_file.config.rss) |rss_root|
409 | try generateRSSFeed(ctx, rss_root);
410 | }
411 |
412 | pub const PostProcessors = struct {
413 | checkmark: processors.CheckmarkProcessor,
414 | cross_page_link: processors.CrossPageLinkProcessor,
415 | };
416 |
417 | pub const EndProcessors = struct {
418 | recent_pages: processors.RecentPagesProcessor,
419 | };
420 |
421 | pub const PreProcessors = struct {
422 | code: processors.CodeblockProcessor,
423 | tag: processors.TagProcessor,
424 | page_toc: processors.TableOfContentsProcessor,
425 | set_first_image: processors.SetFirstImageProcessor,
426 | twitter: processors.StaticTwitterEmbed,
427 | at_dates: processors.AtDatesProcessor,
428 | };
429 |
430 | pub fn initProcessors(comptime ProcessorHolderT: type) !ProcessorHolderT {
431 | var proc: ProcessorHolderT = undefined;
432 | inline for (@typeInfo(ProcessorHolderT).@"struct".fields) |field| {
433 | @field(proc, field.name) = try field.type.init();
434 | }
435 | return proc;
436 | }
437 |
438 | pub fn deinitProcessors(procs: anytype) void {
439 | inline for (@typeInfo(@TypeOf(procs)).@"struct".fields) |field| {
440 | field.type.deinit(@field(procs, field.name));
441 | }
442 | }
443 |
444 | // Contains data that will be sent to the processor
445 | pub fn Holder(comptime ProcessorT: type, comptime WriterT: type) type {
446 | return struct {
447 | ctx: *Context,
448 | processor: ProcessorT,
449 | page: *Page,
450 | last_capture: *?libpcre.Capture,
451 | out: WriterT,
452 | };
453 | }
454 |
455 | const RunProcessorOptions = struct {
456 | pre: bool = false,
457 | end: bool = false,
458 | };
459 |
460 | pub fn runProcessors(
461 | ctx: *Context,
462 | processor_list: anytype,
463 | page: *Page,
464 | options: RunProcessorOptions,
465 | ) !void {
466 | logger.info("running processors processing {} {}", .{ page, options });
467 |
468 | const temp_output_path: []const u8 = if (options.pre) blk: {
469 | std.debug.assert(page.state == .unbuilt);
470 | var markdown_output_path = "/tmp/sex.md"; // TODO fetchTemporaryMarkdownPath();
471 |
472 | try std.fs.Dir.copyFile(
473 | std.fs.cwd(),
474 | page.filesystem_path,
475 | std.fs.cwd(),
476 | markdown_output_path,
477 | .{},
478 | );
479 | break :blk markdown_output_path[0..];
480 | } else blk: {
481 | if (options.end) {
482 | if (page.state != .post) {
483 | logger.err("expected page to be on post state, got {}", .{page.state});
484 | return error.UnexpectedPageState;
485 | }
486 | } else {
487 | if (page.state != .main) {
488 | logger.err("expected page to be on main state, got {}", .{page.state});
489 | return error.UnexpectedPageState;
490 | }
491 | }
492 | break :blk try page.fetchHtmlPath(ctx.allocator);
493 | };
494 |
495 | defer page.state = if (options.pre)
496 | .{ .pre = temp_output_path }
497 | else
498 | .{ .post = {} };
499 |
500 | defer if (!options.pre) ctx.allocator.free(temp_output_path);
501 |
502 | inline for (
503 | @typeInfo(@typeInfo(@TypeOf(processor_list)).pointer.child).@"struct".fields,
504 | ) |field| {
505 | const processor = @field(processor_list, field.name);
506 | logger.debug("running {s}", .{@typeName(field.type)});
507 |
508 | const output_file_contents = blk: {
509 | var output_fd = try std.fs.cwd().openFile(
510 | temp_output_path,
511 | .{ .mode = .read_only },
512 | );
513 | defer output_fd.close();
514 |
515 | break :blk try output_fd.reader().readAllAlloc(
516 | ctx.allocator,
517 | std.math.maxInt(usize),
518 | );
519 | };
520 | defer ctx.allocator.free(output_file_contents);
521 |
522 | var result = ByteList.init(ctx.allocator);
523 | defer result.deinit();
524 |
525 | const HolderT = Holder(@TypeOf(processor), ByteList.Writer);
526 |
527 | var last_capture: ?libpcre.Capture = null;
528 | var context_holder = HolderT{
529 | .ctx = ctx,
530 | .processor = processor,
531 | .page = page,
532 | .last_capture = &last_capture,
533 | .out = result.writer(),
534 | };
535 |
536 | try util.captureWithCallback(
537 | processor.regex,
538 | output_file_contents,
539 | .{},
540 | ctx.allocator,
541 | HolderT,
542 | &context_holder,
543 | struct {
544 | fn inner(
545 | holder: *HolderT,
546 | full_string: []const u8,
547 | capture: []?libpcre.Capture,
548 | ) anyerror!void {
549 | const first_group = capture[0].?;
550 | _ = if (holder.last_capture.* == null)
551 | try holder.out.write(
552 | full_string[0..first_group.start],
553 | )
554 | else
555 | try holder.out.write(
556 | full_string[holder.last_capture.*.?.end..first_group.start],
557 | );
558 |
559 | try holder.processor.handle(
560 | holder,
561 | full_string,
562 | capture,
563 | );
564 | holder.last_capture.* = first_group;
565 | }
566 | }.inner,
567 | );
568 |
569 | _ = if (last_capture == null)
570 | try result.writer().write(output_file_contents)
571 | else
572 | try result.writer().write(
573 | output_file_contents[last_capture.?.end..output_file_contents.len],
574 | );
575 |
576 | {
577 | var output_fd = try std.fs.cwd().openFile(
578 | temp_output_path,
579 | .{ .mode = .write_only },
580 | );
581 | defer output_fd.close();
582 | _ = try output_fd.write(result.items);
583 | }
584 | }
585 | }
586 |
587 | pub fn mainPass(ctx: *Context, page: *Page) !void {
588 | logger.info("processing '{s}'", .{page.filesystem_path});
589 |
590 | // TODO find a way to feed chunks of file to koino
591 | //
592 | // i did that before and failed miserably...
593 | const input_page_contents = blk: {
594 | var page_fd = try std.fs.cwd().openFile(
595 | page.state.pre,
596 | .{ .mode = .read_only },
597 | );
598 | defer page_fd.close();
599 |
600 | break :blk try page_fd.reader().readAllAlloc(
601 | ctx.allocator,
602 | std.math.maxInt(usize),
603 | );
604 | };
605 | defer ctx.allocator.free(input_page_contents);
606 |
607 | const options = koino.Options{
608 | .extensions = .{
609 | .autolink = true,
610 | .strikethrough = true,
611 | .table = true,
612 | },
613 | .render = .{ .hard_breaks = true, .unsafe = true },
614 | };
615 |
616 | var output_fd = blk: {
617 | const html_path = try page.fetchHtmlPath(ctx.allocator);
618 | defer ctx.allocator.free(html_path);
619 | logger.info("writing to '{s}'", .{html_path});
620 |
621 | const leading_path_to_file = std.fs.path.dirname(html_path).?;
622 | try std.fs.cwd().makePath(leading_path_to_file);
623 |
624 | break :blk try std.fs.cwd().createFile(
625 | html_path,
626 | .{ .read = false, .truncate = true },
627 | );
628 | };
629 | defer output_fd.close();
630 |
631 | defer page.state = .{ .main = {} };
632 |
633 | var output = output_fd.writer();
634 |
635 | // write time
636 | {
637 | try writeHead(output, ctx.build_file, page.title, page.*);
638 |
639 | try writePageTree(output, ctx, .{}, page);
640 | try output.print(
641 | \\
642 | , .{});
643 | if (page.titles) |titles| for (titles.items) |title| {
644 | try output.print(
645 | \\ {s}
646 | , .{
647 | util.WebTitlePrinter{ .title = title },
648 | title,
649 | });
650 | };
651 |
652 | try output.print(
653 | \\
654 | , .{});
655 | if (page.tags) |tags| for (tags.items) |tag| {
656 | try output.print(
657 | \\ #{s}
658 | , .{
659 | ctx.webPath("/_/tags/{s}.html", .{tag}),
660 | tag,
661 | });
662 | };
663 |
664 | try output.print(
665 | \\
666 | \\
667 | , .{});
668 | switch (page.page_type) {
669 | .asset => unreachable,
670 | .md => {
671 | try output.print(
672 | \\ {s}
673 | , .{util.unsafeHTML(page.title)});
674 |
675 | var parser = try koino.parser.Parser.init(ctx.allocator, options);
676 | defer parser.deinit();
677 |
678 | try parser.feed(input_page_contents);
679 |
680 | var doc = try parser.finish();
681 | defer doc.deinit();
682 |
683 | try koino.html.print(output, ctx.allocator, options, doc);
684 | },
685 | .canvas => {
686 | // base canvas html goes here
687 |
688 | const CanvasNode = struct {
689 | id: []const u8,
690 | x: isize,
691 | y: isize,
692 | width: usize,
693 | height: usize,
694 | type: []const u8,
695 | text: []const u8,
696 | color: []const u8 = "0",
697 | };
698 |
699 | const CanvasEdge = struct {
700 | id: []const u8,
701 | fromNode: []const u8,
702 | fromSide: []const u8 = "bottom", // NOTE: spec does not define the default.
703 | fromEnd: ?[]const u8 = "none",
704 | toNode: []const u8,
705 | toSide: []const u8 = "top", // NOTE: spec does not define the default.
706 | toEnd: ?[]const u8 = "arrow",
707 | label: ?[]const u8 = null,
708 | };
709 |
710 | const CanvasData = struct {
711 | nodes: []CanvasNode,
712 | edges: []CanvasEdge,
713 | };
714 |
715 | var parsed = try std.json.parseFromSlice(CanvasData, ctx.allocator, input_page_contents, .{ .allocate = .alloc_always });
716 | defer parsed.deinit();
717 |
718 | const canvas = parsed.value;
719 |
720 | try output.print(
721 | \\
722 | \\
723 | \\
733 | \\
734 | , .{});
735 |
736 | for (canvas.nodes) |node| {
737 | // print an html node for each
738 |
739 | var node_parser = try koino.parser.Parser.init(ctx.allocator, options);
740 | defer node_parser.deinit();
741 |
742 | try node_parser.feed(node.text);
743 |
744 | var node_doc = try node_parser.finish();
745 | defer node_doc.deinit();
746 |
747 | var color_class_buf: [32]u8 = undefined;
748 | var color_style_buf: [128]u8 = undefined;
749 | const color_class = if (std.mem.startsWith(u8, node.color, "#"))
750 | ""
751 | else
752 | std.fmt.bufPrint(&color_class_buf, "o2w-canvas-color-{s}", .{node.color}) catch unreachable;
753 |
754 | const color_style = if (std.mem.startsWith(u8, node.color, "#"))
755 | // TODO compute darker color
756 | std.fmt.bufPrint(&color_style_buf, "--color-ui-1: {s}; --color-bg-1: color-mix(in srgb, {s} 20%, black)", .{ node.color, node.color }) catch unreachable
757 | else
758 | "";
759 | try output.print(
760 | \\
761 | \\
762 | \\
763 | , .{
764 | node.id,
765 | color_class,
766 | node.type,
767 | node.x,
768 | node.y,
769 | node.width,
770 | node.height,
771 | color_style,
772 | });
773 | //try output.print("{s}\n", .{node.text});
774 | // don't parse markdown for now
775 | try koino.html.print(output, ctx.allocator, options, node_doc);
776 | try output.print(
777 | \\
778 | \\
779 | , .{});
780 | }
781 |
782 | try output.print(
783 | \\
784 | \\
785 | \\
789 | \\
792 | \\
796 | \\
797 | \\
798 | \\
799 | \\
800 | \\
801 | \\
802 | \\
803 | \\
804 | \\
805 | \\
806 | \\
807 | \\
808 | , .{});
809 |
810 | try output.print(
811 | \\
841 | , .{});
842 |
843 | // inject canvas.js at the end (due to edges declaration)
844 | try output.print(
845 | \\
846 | , .{
847 | ctx.build_file.config.webroot,
848 | });
849 | },
850 | }
851 |
852 | try output.print(
853 | \\
854 | \\ {s}
855 | \\