From 7347325c7d641509480b93def3e730e13369a999 Mon Sep 17 00:00:00 2001 From: TsunamiNoAi Date: Fri, 3 Jul 2026 10:57:49 -0400 Subject: [PATCH 01/12] build: add pozeiden dependency and bump zigmark to 0.7.2 pre-release pin zigmark is pinned to the fix/typst-mermaid-image-emit commit (sc2in/zigmark#70) until v0.7.2 is tagged; swap to ?ref=v0.7.2 before merge. pozeiden is pinned to the 23-update-to-zig-0160 branch head (zig 0.16 support is not on its main yet); the lock resolves the same content via the v0.1.3 tag tarball. --- build.zig.zon | 8 ++++++-- build.zig.zon2json-lock | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index e7e5e58..9426c38 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -42,8 +42,8 @@ // Switch to git+https URLs with hashes before publishing to FlakeHub. // .zetta = .{ .path = "../zetta" }, .zigmark = .{ - .url = "git+https://github.com/sc2in/zigmark#e590c0a1d707744811d936b038d4a7e0780b32e9", - .hash = "zigmark-0.7.0-cwOiyN4KCgAHRBp6kiQE2fbckRQiOUfPQNq9rYPtOrqG", + .url = "git+https://github.com/sc2in/zigmark#9c8a99caaa613b44886ca5ff04fb8ab841b4d484", + .hash = "zigmark-0.7.2-cwOiyIoYCgD20PuJUYu7uQyFLcng6H7Xg09j-QEivL44", }, .tomlz = .{ .url = "git+https://github.com/sc2in/tomlz.git#ecd64e239768b573ec58286d0db1842991433e99", @@ -61,6 +61,10 @@ .url = "git+https://github.com/Hejsil/zig-clap#1d3d273524e3c180f015ff1e93c83075e4634e2c", .hash = "clap-0.11.0-oBajB-jlAQA8x4XSScN1d48Q83iYl-LDU63htNyXbXBe", }, + .pozeiden = .{ + .url = "git+https://github.com/sc2in/pozeiden#19259125f772cb44117e7a77575292994fd7463c", + .hash = "pozeiden-0.1.3-NAqiXeOXCgAUNGkydN-lIYUZQ87uzGQPOQ-wXvK4FNW3", + }, }, .paths = .{ "build.zig", diff --git a/build.zig.zon2json-lock b/build.zig.zon2json-lock index 96384c4..7f92bf7 100644 --- a/build.zig.zon2json-lock +++ b/build.zig.zon2json-lock @@ -1,8 +1,8 @@ { - "zigmark-0.7.0-cwOiyN4KCgAHRBp6kiQE2fbckRQiOUfPQNq9rYPtOrqG": { + "zigmark-0.7.2-cwOiyIoYCgD20PuJUYu7uQyFLcng6H7Xg09j-QEivL44": { "name": "zigmark", - "url": "git+https://github.com/sc2in/zigmark#e590c0a1d707744811d936b038d4a7e0780b32e9", - "hash": "sha256-Z8sTn+5PYYlqxcXEjKl4BShYtV4RDwvgKwn/2ErfuGE=" + "url": "git+https://github.com/sc2in/zigmark#9c8a99caaa613b44886ca5ff04fb8ab841b4d484", + "hash": "sha256-URfWS415QXUs+Tz8cJogCBgmlj6U4f1b4Nsp2zV5AFc=" }, "tomlz-0.3.0-H2E6w3VKAgCSZQFKG-VugLEn3cjOWshGqwGzAVABNm--": { "name": "tomlz", From 1a444653a18cc814d3504b894d751f8cf9ea0069 Mon Sep 17 00:00:00 2001 From: TsunamiNoAi Date: Fri, 3 Jul 2026 11:00:07 -0400 Subject: [PATCH 02/12] feat(utils): add underscoresToBlocks and executableInPath helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit underscoresToBlocks converts redaction underscores to solid █ bars for the typst engine (CommonMark parses 3+ underscores as thematic breaks, which render as thin gray rules instead of redaction marks). executableInPath moves from pandoc.zig so it survives the pandoc teardown. --- src/utils.zig | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/src/utils.zig b/src/utils.zig index 882e49d..df76760 100644 --- a/src/utils.zig +++ b/src/utils.zig @@ -5,6 +5,7 @@ const Array = std.ArrayList; const Allocator = std.mem.Allocator; const tst = std.testing; const math = std.math; +const builtin = @import("builtin"); const mvzr = @import("mvzr"); const zigmark = @import("zigmark"); @@ -371,6 +372,110 @@ pub fn redact(a: Allocator, txt: *Array(u8), remove: bool) !void { txt.* = new; } +/// Replace `_` characters in the **body** of `txt` (after the frontmatter) with +/// the UTF-8 solid-block character `█` (U+2588). +/// +/// `redact` fills redacted spans with underscores. In CommonMark, a run of +/// three or more `_` on its own line is a thematic break, so zigmark renders it +/// as a thin gray rule. Replacing with `█` produces proper black-bar redaction +/// marks without any special Markdown meaning. +/// +/// Only the body is processed; the frontmatter block (keys like `last_reviewed`, +/// `major_revisions`) must remain intact for the later metadata extraction pass. +pub fn underscoresToBlocks(alloc: Allocator, txt: *Array(u8)) !void { + const block = "█"; // 3-byte UTF-8: 0xE2 0x96 0x88 + if (std.mem.indexOfScalar(u8, txt.items, '_') == null) return; + + // Locate the end of the frontmatter so we leave it untouched. + const content = txt.items; + const body_start: usize = blk: { + if (content.len < 4) break :blk 0; + const delim: []const u8 = switch (content[0]) { + '-' => "---", + '+' => "+++", + else => break :blk 0, + }; + const close = std.mem.indexOfPos(u8, content, 3, delim) orelse break :blk 0; + break :blk close + delim.len; + }; + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + // Copy the frontmatter verbatim. + try aw.writer.writeAll(content[0..body_start]); + // Replace underscores in the body with █. + // Insert a space every 10 blocks so Typst can wrap the bar within the text + // width (redact replaces spaces too, producing one unbreakable "word"). + var block_count: usize = 0; + for (content[body_start..]) |c| { + if (c == '_') { + try aw.writer.writeAll(block); + block_count += 1; + if (block_count % 10 == 0) try aw.writer.writeByte(' '); + } else { + block_count = 0; + try aw.writer.writeByte(c); + } + } + const new_bytes = try aw.toOwnedSlice(); + txt.deinit(alloc); + txt.* = Array(u8){ .items = new_bytes, .capacity = new_bytes.len }; +} + +test "underscoresToBlocks replaces body underscores with solid blocks" { + const allocator = tst.allocator; + var arr = Array(u8).empty; + defer arr.deinit(allocator); + try arr.appendSlice(allocator, "some ____ text"); + + try underscoresToBlocks(allocator, &arr); + try tst.expectEqualStrings("some ████ text", arr.items); +} + +test "underscoresToBlocks leaves frontmatter untouched" { + const allocator = tst.allocator; + var arr = Array(u8).empty; + defer arr.deinit(allocator); + try arr.appendSlice(allocator, "---\nlast_reviewed: 2026-01-01\n---\nbody __ here"); + + try underscoresToBlocks(allocator, &arr); + try tst.expectEqualStrings("---\nlast_reviewed: 2026-01-01\n---\nbody ██ here", arr.items); +} + +test "underscoresToBlocks inserts a break space every 10 blocks" { + const allocator = tst.allocator; + var arr = Array(u8).empty; + defer arr.deinit(allocator); + try arr.appendSlice(allocator, "_" ** 12); + + try underscoresToBlocks(allocator, &arr); + try tst.expectEqualStrings("██████████ ██", arr.items); +} + +test "underscoresToBlocks is a no-op without underscores" { + const allocator = tst.allocator; + var arr = Array(u8).empty; + defer arr.deinit(allocator); + try arr.appendSlice(allocator, "clean text"); + + try underscoresToBlocks(allocator, &arr); + try tst.expectEqualStrings("clean text", arr.items); +} + +/// Returns true when `name` resolves to an executable on the PATH. +pub fn executableInPath(io: std.Io, env: *std.process.Environ.Map, name: []const u8) bool { + if (comptime builtin.os.tag == .windows) return false; + const path_env = env.get("PATH") orelse return false; + var it = std.mem.tokenizeScalar(u8, path_env, ':'); + var buf: [std.fs.max_path_bytes]u8 = undefined; + while (it.next()) |dir| { + const full = std.fmt.bufPrint(&buf, "{s}/{s}", .{ dir, name }) catch continue; + std.Io.Dir.accessAbsolute(io, full, .{}) catch continue; + return true; + } + return false; +} + test "Redaction" { const t = \\{% redact() %} From dc771cd1ff13e084acd431fa51ec1cd19a125b33 Mon Sep 17 00:00:00 2001 From: TsunamiNoAi Date: Fri, 3 Jul 2026 11:20:44 -0400 Subject: [PATCH 03/12] feat!: replace pandoc/xelatex PDF engine with typst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New src/typst.zig renders policies in-process: markdown → utils preprocessing (unchanged) → zigmark AST → Typst markup with an eisvogel-matching preamble (title page + colored rule + 6cm logo, 3-column header/footer, zebra tables, TOC depth 3, 5-column version history incl. Revised By) → 'typst compile'. Mermaid diagrams render via pozeiden to inline SVG — no mermaid-filter/node/Chromium, and they now work inside the nix sandbox and on macOS. Parity preserved: CLI flags, output filename patterns (issue #97), draft.png watermark resolution (site root → theme fallback), redaction bars (underscores → █ via utils.underscoresToBlocks). pozeiden is not thread-safe (module-global arena and lazily initialised grammar caches) while policies compile concurrently, so the mermaid wrapper serialises calls behind a spinlock (zigmark's hook signature has no std.Io for an Io.Mutex). Removed: src/pandoc.zig, templates/eisvogel.latex (embed machinery in build.zig), .puppeteer.json, Config.data_dir. The 'pdf' build step now runs the typst engine standalone; e2e no longer copies the LaTeX template into starter/. Tests: pdf rendering gates on typst instead of xelatex and now exercises the mermaid fixture; new typst-source tests for inline SVG, redaction bars, and the draft background; draft.png resolution tests target Typst.resolveDraftPng. --- .puppeteer.json | 3 - build.zig | 72 +-- build.zig.zon | 1 - src/config.zig | 4 - src/main.zig | 32 +- src/pandoc.zig | 486 ---------------- src/server.zig | 2 +- src/test.zig | 151 ++--- src/typst.zig | 754 +++++++++++++++++++++++++ templates/eisvogel.latex | 1142 -------------------------------------- 10 files changed, 869 insertions(+), 1778 deletions(-) delete mode 100644 .puppeteer.json delete mode 100644 src/pandoc.zig create mode 100644 src/typst.zig delete mode 100644 templates/eisvogel.latex diff --git a/.puppeteer.json b/.puppeteer.json deleted file mode 100644 index 2274c80..0000000 --- a/.puppeteer.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "args": ["--no-sandbox", "--disable-setuid-sandbox"] -} diff --git a/build.zig b/build.zig index 61aca97..c5249d5 100644 --- a/build.zig +++ b/build.zig @@ -43,6 +43,12 @@ pub fn build(b: *std.Build) !void { }); const zigmark_mod = zigmark_dep.module("zigmark"); + const pozeiden_dep = b.dependency("pozeiden", .{ + .target = target, + .optimize = optimize, + }); + const pozeiden_mod = pozeiden_dep.module("pozeiden"); + const config_mod = b.addModule("config_parser", .{ .root_source_file = b.path("src/config.zig"), .target = target, @@ -62,8 +68,6 @@ pub fn build(b: *std.Build) !void { // config uses utils for Date/today and readAllAlloc helpers. config_mod.addImport("utils", utils_mod); - var pandoc_sh: *std.Build.Step.Compile = undefined; - // the executable from your call to exe_mod.addExecutable if (target.result.os.tag != .windows) { const zap = b.dependency("zap", .{ @@ -94,43 +98,29 @@ pub fn build(b: *std.Build) !void { _ = b.step("preview", "Serve the zola output (Not available on Windows. run `zola preview` instead.)"); } - // Inject the eisvogel template into the pandoc module via an anonymous - // module. build.zig can @embedFile at the project root level, bypassing - // Zig's module package-path restriction that applies inside src/pandoc.zig. - // A WriteFile step generates a tiny Zig wrapper that @embedFile the real - // template; the wrapper lives in the Zig cache, not src/, so paths resolve. - const write_eisvogel = b.addWriteFiles(); - _ = write_eisvogel.addCopyFile(b.path("templates/eisvogel.latex"), "eisvogel.latex"); - const eisvogel_wrapper = write_eisvogel.add("eisvogel_wrapper.zig", - \\pub const eisvogel_latex: []const u8 = @embedFile("eisvogel.latex"); - ); - const pandoc_opts_mod = b.addModule("pandoc_options", .{ - .root_source_file = eisvogel_wrapper, - }); - - const pandoc_sh_mod = b.addModule("pandocsh", .{ - .root_source_file = b.path("src/pandoc.zig"), + // Typst PDF engine: markdown → zigmark → Typst markup (mermaid via + // pozeiden) → `typst compile`. + const typst_mod = b.addModule("typst_engine", .{ + .root_source_file = b.path("src/typst.zig"), .target = target, .optimize = optimize, }); - // pandoc_sh_mod.addImport("zetta", zetta_mod); - pandoc_sh_mod.addImport("clap", clap.module("clap")); - pandoc_sh_mod.addImport("mvzr", mvzr.module("mvzr")); - pandoc_sh_mod.addImport("config", config_mod); - pandoc_sh_mod.addImport("utils", utils_mod); - pandoc_sh_mod.addImport("pandoc_options", pandoc_opts_mod); - pandoc_sh = b.addExecutable(.{ - .root_module = pandoc_sh_mod, - .name = "pandoc_sh", + typst_mod.addImport("clap", clap.module("clap")); + typst_mod.addImport("config", config_mod); + typst_mod.addImport("utils", utils_mod); + typst_mod.addImport("zigmark", zigmark_mod); + typst_mod.addImport("pozeiden", pozeiden_mod); + const typst_exe = b.addExecutable(.{ + .root_module = typst_mod, + .name = "pdf_engine", }); - var pandoc_step = b.step("pdf", "run pandoc.sh"); - const pandoc_exe = b.addRunArtifact(pandoc_sh); + const pdf_step = b.step("pdf", "Compile a single policy to PDF via the typst engine"); + const pdf_run = b.addRunArtifact(typst_exe); if (b.args) |args| { - pandoc_exe.addArgs(args); + pdf_run.addArgs(args); } - - pandoc_step.dependOn(&pandoc_exe.step); + pdf_step.dependOn(&pdf_run.step); const reports_mod = b.addModule("policy_report", .{ .target = target, @@ -152,7 +142,7 @@ pub fn build(b: *std.Build) !void { policypress_mod.addImport("build_options", build_opts.createModule()); policypress_mod.addImport("clap", clap.module("clap")); policypress_mod.addImport("config", config_mod); - policypress_mod.addImport("pandoc", pandoc_sh_mod); + policypress_mod.addImport("typst", typst_mod); policypress_mod.addImport("reports", reports_mod); policypress_mod.addImport("utils", utils_mod); const policypress_exe = b.addExecutable(.{ @@ -188,8 +178,7 @@ pub fn build(b: *std.Build) !void { }); test_module.addImport("zigmark", zigmark_mod); test_module.addImport("utils", utils_mod); - test_module.addImport("pandoc", pandoc_sh_mod); - test_module.addImport("pandoc_options", pandoc_opts_mod); + test_module.addImport("typst", typst_mod); test_module.addImport("config", config_mod); test_module.addImport("reports", reports_mod); test_module.addImport("mvzr", mvzr.module("mvzr")); @@ -206,21 +195,18 @@ pub fn build(b: *std.Build) !void { // Used by ZLS for IDE diagnostics and as a fast CI sanity check. const check_step = b.step("check", "Semantic analysis (no binary emitted, used by ZLS)"); check_step.dependOn(&policypress_exe.step); - check_step.dependOn(&pandoc_sh.step); + check_step.dependOn(&typst_exe.step); } { // E2E test: run policypress against the starter/ template, mirroring - // what action.yml does for real consumers. Requires pandoc + XeLaTeX - // in PATH (provided by the devshell / Nix flake check environment). + // what action.yml does for real consumers. Requires typst in PATH + // (provided by the devshell / Nix flake check environment). const e2e_step = b.step("e2e", "Run end-to-end test against starter/"); - // Step 1: copy eisvogel.latex into starter/templates/ (the action does - // this via "Copy pandoc templates"; consumers don't vendor it). - // Also ensure starter/static/logo.png exists (required by config.toml). + // Step 1: ensure starter/static/logo.png exists (required by config.toml). const copy_template = b.addSystemCommand(&.{ "bash", "-c", - "mkdir -p starter/templates starter/static && " ++ - "cp -f templates/eisvogel.latex starter/templates/ && " ++ + "mkdir -p starter/static && " ++ "{ cp -f static/logo.png starter/static/logo.png 2>/dev/null || " ++ "touch starter/static/logo.png; }", }); diff --git a/build.zig.zon b/build.zig.zon index 9426c38..8d7e20c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -70,7 +70,6 @@ "build.zig", "build.zig.zon", "src", - "templates/eisvogel.latex", "LICENSE", "README.md", "SECURITY.md", diff --git a/src/config.zig b/src/config.zig index a1b8489..9f6bcab 100644 --- a/src/config.zig +++ b/src/config.zig @@ -32,9 +32,6 @@ pub const Config = struct { is_draft: bool = false, redact: bool = false, build_dir: []const u8, - /// Temporary directory containing the embedded eisvogel.latex template, - /// passed to pandoc as --data-dir. Owned and freed by the caller. - data_dir: []const u8 = "", date: u.Date, zola_config: ?toml.Table, @@ -102,7 +99,6 @@ pub const Config = struct { // if (b.color.len == 0) return error.NoPDFColorInExtra; // if (b.org.len == 0) return error.NoOrganizationInExtra; var config: Config = undefined; - config.data_dir = ""; config.is_draft = false; config.date = u.Date.today(io); diff --git a/src/main.zig b/src/main.zig index 8a86b83..f21c4f6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,7 +3,8 @@ //! //! This program automates the process of converting Markdown policy documents into styled PDF files. //! It loads configuration from a TOML file, processes Markdown files (including YAML front matter and custom placeholders), -//! applies organization branding, and invokes Pandoc with a set of dynamically constructed arguments to generate PDFs. +//! applies organization branding, renders the markdown to Typst markup in-process (zigmark; mermaid +//! diagrams via pozeiden), and invokes `typst compile` to generate PDFs. //! The build is highly configurable, supporting custom logos, organization names, color extraction from images, //! and options for draft/redacted document states. The system is designed for batch processing of policy directories, //! with robust error handling and logging at multiple stages of the pipeline. @@ -16,7 +17,7 @@ const build_options = @import("build_options"); const clap = @import("clap"); const Config = @import("config").Config; -const Pandoc = @import("pandoc"); +const Typst = @import("typst"); const Reports = @import("reports"); const stampIsNewer = @import("utils").stampIsNewer; const writeStamp = @import("utils").writeStamp; @@ -184,7 +185,7 @@ fn runBuild(io: std.Io, env: *EnvMap, alloc: Allocator, args: []const [:0]const \\--no-draft Do not add draft watermark to output (overrides config.toml). \\--redact Redact content within redaction tags (overrides config.toml). \\--no-redact Do not redact text within redaction tags (overrides config.toml). - \\-v, --verbose Show debug output (pandoc args, file paths). + \\-v, --verbose Show debug output (typst invocations, file paths). \\-q, --quiet Suppress progress output; show errors only. \\ --json Emit log output as JSON lines (for CI). ); @@ -316,25 +317,6 @@ fn runBuild(io: std.Io, env: *EnvMap, alloc: Allocator, args: []const [:0]const const output_path = if (res.args.output) |o| o else default_output; config.build_dir = output_path; - // Write the embedded eisvogel.latex to a tmpdir so pandoc can find it - // without the consumer needing to vendor the template in their repository. - const data_dir_path = blk: { - const base = env.get("TMPDIR") orelse env.get("TMP") orelse "/tmp"; - // Random suffix instead of pid: getpid was a raw Linux syscall and - // not portable to the macOS/Windows builds. - var run_id: u64 = undefined; - io.random(std.mem.asBytes(&run_id)); - break :blk try std.fmt.allocPrint(alloc, "{s}/pp-data-{x}", .{ base, run_id }); - }; - defer alloc.free(data_dir_path); - std.Io.Dir.cwd().createDirPath(io, data_dir_path) catch {}; - defer std.Io.Dir.cwd().deleteTree(io, data_dir_path) catch {}; - config.data_dir = Pandoc.writeEisvogel(io, alloc, data_dir_path) catch |err| blk: { - std.debug.print("policypress: warning: could not write embedded template ({s}), falling back to --data-dir=.\n", .{@errorName(err)}); - break :blk config.root; - }; - defer if (!std.mem.eql(u8, config.data_dir, config.root)) alloc.free(config.data_dir); - std.Io.Dir.cwd().createDirPath(io, output_path) catch |err| { std.debug.print( "policypress: cannot create output directory '{s}': {s}\n", @@ -515,8 +497,8 @@ fn describeCompileError(err: anyerror) []const u8 { error.NoDescriptionForRevision => "a revision entry is missing the 'description' field", error.InvalidShortCode => "a shortcode block ({% ... %}) is malformed - check for missing {% end %}", error.NoResourcePathDefined => "could not determine resource path from the file's location", - error.PandocFailed => "pandoc exited with an error - check the output above for details", - error.PandocNotFound => "pandoc was not found; make sure you are running inside the PolicyPress devshell (nix develop)", + error.TypstFailed => "typst exited with an error - check the output above for details", + error.TypstNotFound => "typst was not found; run inside the PolicyPress devshell (nix develop) or install it from https://typst.app/open-source/", error.FileNotFound => "policy file was not found on disk (it may have been deleted mid-build)", error.OutOfMemory => "out of memory while processing this file", else => @errorName(err), @@ -538,7 +520,7 @@ fn compileOne( ) void { defer progress_node.completeOne(); - Pandoc.compile(io, env, alloc, config, input_path) catch |err| { + Typst.compile(io, env, alloc, config, input_path) catch |err| { error_mutex.lockUncancelable(io); defer error_mutex.unlock(io); error_count.* += 1; diff --git a/src/pandoc.zig b/src/pandoc.zig deleted file mode 100644 index 49e5089..0000000 --- a/src/pandoc.zig +++ /dev/null @@ -1,486 +0,0 @@ -//! Copyright © 2025 [Star City Security Consulting, LLC (SC2)](https://sc2.in) -//! SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -const std = @import("std"); -const Array = std.ArrayList; -const Allocator = std.mem.Allocator; -const tst = std.testing; -const math = std.math; -const builtin = @import("builtin"); - -const clap = @import("clap"); -const Config = @import("config").Config; -const mvzr = @import("mvzr"); -const u = @import("utils"); - -const EnvMap = std.process.Environ.Map; - -// TODO: Add more robust error propegation from pandoc/mermaid-filter -// TODO?: Link against pandoc directly at somepoint - -pub const std_options: std.Options = .{ - .log_level = .warn, - .log_scope_levels = &[_]std.log.ScopeLevel{ - .{ .scope = .parser, .level = .warn }, - .{ .scope = .pandoc, .level = .warn }, - .{ .scope = .yaml, .level = .err }, - }, - .logFn = u.logFn, -}; - -const panlog = std.log.scoped(.pandoc); - -pub fn compile( - io: std.Io, - env: *EnvMap, - alloc: Allocator, - config: Config, - input_file: []const u8, -) !void { - var global_args = Array([]u8).empty; - - try create_global_args(io, env, alloc, &global_args, config); - defer destroy_global_args(alloc, &global_args); - - try process_md_file(io, env, alloc, .{ .path = input_file }, global_args, config); -} -pub fn main(init: std.process.Init) !void { - const io = init.io; - const alloc = init.gpa; - const env = init.environ_map; - - var config = try Config.load_config_toml(io, alloc); - defer config.deinit(alloc); - - var workfile: ?[]u8 = null; - defer { - if (workfile) |w| alloc.free(w); - } - - const params = comptime clap.parseParamsComptime( - \\-h, --help Display this help and exit. - \\-d, --draft Add draft watermark to output. - \\-r, --redact Redact text within redaction tags in output. - \\--org Organization name - \\-o, --output Destination folder - \\-i, --input Input file - ); - var buf: [128]u8 = undefined; - - // Report useful error and exit. - var stderr = std.Io.File.stderr().writer(io, &buf).interface; - var diag = clap.Diagnostic{}; - var res = clap.parse(clap.Help, ¶ms, clap.parsers.default, init.minimal.args, .{ - .diagnostic = &diag, - .allocator = alloc, - }) catch |err| { - diag.report(&stderr, err) catch {}; - return err; - }; - // std.debug.print("{any}", .{res}); - defer res.deinit(); - if (res.args.help != 0) { - std.debug.print("PolicyPress PDF Generator\nSee Readme.md or run `devbox build docs` to learn more.\n\n", .{}); - return clap.help(&stderr, clap.Help, ¶ms, .{}); - } - - if (res.args.output) |c| { - panlog.info("Writing to: {s}\n", .{c}); - config.build_dir = try alloc.dupe(u8, c); - } else return error.OutputDirNotProvided; - if (res.args.input) |c| { - panlog.info("Input File: {s}\n", .{c}); - workfile = try alloc.dupe(u8, c); - } else return error.InputFileNotProvided; - - if (res.args.draft != 0) { - panlog.info("Draft mode enabled\n", .{}); - config.is_draft = true; - } - if (res.args.redact != 0 or config.redact == true) { - panlog.info("Redaction enabled\n", .{}); - config.redact = true; - } - - panlog.debug("Running with Configuration:\n{f}\n", .{config}); - - var global_args = Array([]u8).empty; - - try create_global_args(io, env, alloc, &global_args, config); - defer destroy_global_args(alloc, &global_args); - if (workfile) |w| - try process_md_file( - io, - env, - alloc, - .{ .path = w }, - global_args, - config, - ) - else - return error.InputFileNotProvided; -} - -pub fn destroy_global_args(a: Allocator, args: *Array([]u8)) void { - for (args.items) |arg| - a.free(arg); - args.deinit(a); -} - -/// Writes the embedded eisvogel.latex into `/templates/eisvogel.latex` -/// and returns the allocated path to `` for use as `--data-dir`. -/// The template is injected at build time via the `pandoc_options` module. -/// Caller owns the returned slice. -pub fn writeEisvogel(io: std.Io, a: Allocator, dir: []const u8) ![]const u8 { - const tmpl_dir = try std.fs.path.join(a, &.{ dir, "templates" }); - defer a.free(tmpl_dir); - try std.Io.Dir.cwd().createDirPath(io, tmpl_dir); - const tmpl_path = try std.fs.path.join(a, &.{ tmpl_dir, "eisvogel.latex" }); - defer a.free(tmpl_path); - const f = try std.Io.Dir.createFileAbsolute(io, tmpl_path, .{ .truncate = true }); - defer f.close(io); - try f.writeStreamingAll(io, @import("pandoc_options").eisvogel_latex); - return try a.dupe(u8, dir); -} - -///Populates the global_args array with command-line arguments for Pandoc, based on the current global configuration -pub fn create_global_args(io: std.Io, env: *EnvMap, a: Allocator, args: *Array([]u8), config: Config) !void { - const data_dir = if (config.data_dir.len > 0) config.data_dir else config.root; - try add_arg(a, args, "", "--data-dir={s}", .{data_dir}); - try add_arg(a, args, "", "--resource-path={s}", .{config.root}); - try add_arg(a, args, "-V", "footer-left={s} \\textcopyright {d}", .{ config.org, config.current_year }); - - // LaTeX treats '%' as a comment character in file paths fed to \includegraphics. - // Copy the logo to a unique temp path without special characters when the path contains '%'. - const logo_for_latex = if (std.mem.indexOfScalar(u8, config.logo_path, '%') != null) blk: { - const ext = std.fs.path.extension(config.logo_path); - var attempt: usize = 0; - while (attempt < 16) : (attempt += 1) { - var rnd: u64 = undefined; - io.random(std.mem.asBytes(&rnd)); - const tmp_path = try std.fmt.allocPrint(a, "/tmp/pp-logo-{x}{s}", .{ rnd, ext }); - // Claim the path exclusively to avoid races, then overwrite with the real content. - const tmp_file = std.Io.Dir.createFileAbsolute(io, tmp_path, .{ .exclusive = true }) catch |err| { - a.free(tmp_path); - if (err == error.PathAlreadyExists) continue; - std.log.warn("could not create temp logo file: {}", .{err}); - break :blk try a.dupe(u8, config.logo_path); - }; - tmp_file.close(io); - std.Io.Dir.copyFileAbsolute(config.logo_path, tmp_path, io, .{}) catch |err| { - std.Io.Dir.deleteFileAbsolute(io, tmp_path) catch {}; - a.free(tmp_path); - std.log.warn("could not copy logo to temp path: {}", .{err}); - break :blk try a.dupe(u8, config.logo_path); - }; - break :blk tmp_path; - } - std.log.warn("could not allocate a unique temp logo file name", .{}); - break :blk try a.dupe(u8, config.logo_path); - } else try a.dupe(u8, config.logo_path); - defer a.free(logo_for_latex); - try add_arg(a, args, "-V", "header-right=\\includegraphics[width=3cm,height=2cm,keepaspectratio]{{{s}}}", .{logo_for_latex}); - - try add_arg(a, args, "-V", "titlepage-logo={s}", .{logo_for_latex}); - - try add_arg(a, args, "-V", "institution=\"{s}\"", .{config.org}); - - try add_arg(a, args, "-V", "titlepage-rule-color={s}", .{if (config.color[0] == '#') config.color[1..] else config.color}); - - if (executableInPath(io, env, "mermaid-filter")) - try add_arg(a, args, "-F", "mermaid-filter", .{}); - try add_arg(a, args, "-V", "footer-center=Confidential", .{}); - try add_arg(a, args, "-V", "papersize=letter", .{}); - try add_arg(a, args, "-V", "titlepage=true", .{}); - try add_arg(a, args, "-V", "toc-own-page=true ", .{}); - try add_arg(a, args, "-V", "toc=true", .{}); - try add_arg(a, args, "-V", "toc-depth=3", .{}); - try add_arg(a, args, "-V", "logo-width=6cm", .{}); - try add_arg(a, args, "-V", "table-use-row-colors=true", .{}); - try add_arg(a, args, "--template", "eisvogel", .{}); - try add_arg(a, args, "", "--listings", .{}); - try add_arg(a, args, "", "--webtex", .{}); - try add_arg(a, args, "", "--pdf-engine=xelatex", .{}); - - if (config.is_draft) { - const draft_path: ?[]const u8 = blk: { - const primary = try std.fs.path.join(a, &.{ config.root, "static", "draft.png" }); - if (std.Io.Dir.accessAbsolute(io, primary, .{})) |_| { - break :blk primary; - } else |err| switch (err) { - error.FileNotFound => a.free(primary), - else => return err, - } - - // When policypress is used as a Zola theme (submodule), the watermark - // lives under themes/policypress/static/ rather than at the site root. - const fallback = try std.fs.path.join(a, &.{ config.root, "themes", "policypress", "static", "draft.png" }); - if (std.Io.Dir.accessAbsolute(io, fallback, .{})) |_| { - break :blk fallback; - } else |err| switch (err) { - error.FileNotFound => { - a.free(fallback); - std.log.warn("draft.png not found at site root or in themes/policypress/static/; draft watermark will be skipped", .{}); - break :blk null; - }, - else => return err, - } - }; - if (draft_path) |path| { - defer a.free(path); - try add_arg(a, args, "-V", "page-background={s}", .{path}); - try add_arg(a, args, "-V", "page-background-opacity=0.8", .{}); - } - } -} - -pub fn executableInPath(io: std.Io, env: *EnvMap, name: []const u8) bool { - if (comptime builtin.os.tag == .windows) return false; - const path_env = env.get("PATH") orelse return false; - var it = std.mem.tokenizeScalar(u8, path_env, ':'); - var buf: [std.fs.max_path_bytes]u8 = undefined; - while (it.next()) |dir| { - const full = std.fmt.bufPrint(&buf, "{s}/{s}", .{ dir, name }) catch continue; - std.Io.Dir.accessAbsolute(io, full, .{}) catch continue; - return true; - } - return false; -} - -inline fn add_arg( - a: Allocator, - args: *Array([]u8), - comptime prefix: []const u8, - comptime fmt: []const u8, - value: anytype, -) !void { - if (prefix.len > 0) try args.append(a, try a.dupe(u8, prefix)); - - const arg = try std.fmt.allocPrint(a, fmt, value); - try args.append(a, arg); -} - -/// Processes a single markdown file: loads contents, applies replacements, extracts metadata, writes a temporary file, and invokes Pandoc to generate the PDF. -pub fn process_md_file( - io: std.Io, - env: *EnvMap, - a: Allocator, - md: u.MDFile, - global_args: Array([]u8), - config: Config, -) !void { - panlog.debug("Processing markdown file: {s}\n", .{md.path}); - var dir = try std.Io.Dir.cwd().openDir(io, config.root, .{}); - defer dir.close(io); - var file = dir.openFile(io, md.path, .{ .mode = .read_only }) catch |e| { - if (e == error.FileNotFound) { - panlog.err("File: {s}/{s} not found\n", .{ config.root, md.path }); - } - return e; - }; - defer file.close(io); - - const raw = try u.readAllAlloc(io, file, a, 100_000_000); - var contents = Array(u8){ - .items = raw, - .capacity = raw.len, - }; - defer contents.deinit(a); - var local = Array([]u8).empty; - defer destroy_global_args(a, &local); - - try u.replace_org(a, &contents, config.org); - try u.replace_zola_at(a, &contents, config.base_url); - try u.replace_admonitions(a, &contents); - try u.replace_mermaid(a, &contents); - try u.redact(a, &contents, config.redact); - - var fm = try u.get_metadata(a, &contents, config); - defer fm.deinit(a); - - // Write the preprocessed markdown to a file in the system temp directory - // rather than the output directory. This keeps .md files out of paths that - // watchexec monitors, preventing false rebuild triggers. - const tmpdir = blk: { - const candidates = [_]?[]const u8{ env.get("TMPDIR"), env.get("TMP"), "/tmp" }; - for (candidates) |maybe| { - const d = maybe orelse continue; - std.Io.Dir.accessAbsolute(io, d, .{}) catch continue; - break :blk d; - } - break :blk "/tmp"; - }; - // Random suffix instead of pid: portable across OSes (getpid was a raw - // Linux syscall), and unique per task even within one process, so - // concurrent compiles of same-named files cannot collide. - var tmp_id: u64 = undefined; - io.random(std.mem.asBytes(&tmp_id)); - const tmp_name = try std.fmt.allocPrint(a, "pp_{x}_{s}", .{ tmp_id, std.fs.path.basename(md.path) }); - defer a.free(tmp_name); - const tmp_abs = try std.fs.path.join(a, &.{ tmpdir, tmp_name }); - defer a.free(tmp_abs); - const tmp = std.Io.Dir.createFileAbsolute(io, tmp_abs, .{ .exclusive = true }) catch |e| blk: { - if (e == error.PathAlreadyExists) { - std.Io.Dir.deleteFileAbsolute(io, tmp_abs) catch {}; - break :blk try std.Io.Dir.createFileAbsolute(io, tmp_abs, .{}); - } - return e; - }; - defer { - tmp.close(io); - std.Io.Dir.deleteFileAbsolute(io, tmp_abs) catch {}; - } - try tmp.writeStreamingAll(io, contents.items); - - // Verify output directory is still accessible before invoking pandoc. - std.Io.Dir.cwd().access(io, config.build_dir, .{}) catch |e| { - panlog.err("Could not access build directory: {s}\nError: {}\n", .{ config.build_dir, e }); - return e; - }; - - try local.insertSlice(a, 0, &.{try a.dupe(u8, "pandoc")}); - var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; - const cwd_len = try std.process.currentPath(io, &cwd_buf); - const cwd = try a.dupe(u8, cwd_buf[0..cwd_len]); - defer a.free(cwd); - - const basedir = if (std.fs.path.dirname(md.path)) |d| try a.dupe(u8, d) else return error.NoResourcePathDefined; - defer a.free(basedir); - const res_path = try std.fmt.allocPrint(a, "--resource-path={s}:{s}:{s}/templates", .{ env.get("PATH") orelse "", basedir, cwd }); - try local.append(a, res_path); - - // Pass the absolute temp file path and tell pandoc to read it as markdown - // (since the .md extension is on the tmp file, format inference still works). - try local.append(a, try a.dupe(u8, tmp_abs)); - - const out = try fm.filename(a); - defer a.free(out); - std.mem.replaceScalar(u8, out, ' ', '_'); - - // Sanitize the output filename to prevent path traversal and unsafe characters. - var prev_dot = false; - for (out, 0..) |*ch, idx| { - var c = ch.*; - // Replace any path separators with an underscore. - if (c == '/' or c == '\\') { - c = '_'; - } - // Allow only alphanumerics, '_', '-', and '.'; map others to '_'. - if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-' and c != '.') { - c = '_'; - } - // Prevent leading '.' and ".." sequences. - if (c == '.') { - if (idx == 0 or prev_dot) { - c = '_'; - prev_dot = false; - } else { - prev_dot = true; - } - } else { - prev_dot = false; - } - ch.* = c; - } - - try add_arg(a, &local, "-o", "{s}{s}{s}", .{ config.build_dir, "/", out }); - - var combined = Array([]const u8).empty; - defer combined.deinit(a); - - try combined.appendSlice(a, local.items); - try combined.appendSlice(a, global_args.items); - - try run_pandoc(io, env, a, combined); -} - -/// Spawns a Pandoc process with the provided arguments, collects output, and logs errors or results as needed. -pub fn run_pandoc(io: std.Io, env: *EnvMap, a: Allocator, args: Array([]const u8)) !void { - panlog.debug("Running pandoc with args:\n", .{}); - for (args.items) |arg| - panlog.debug("\t{s}\n", .{arg}); - - // Work on a private copy of the environment: the build pipeline runs policies - // concurrently and shares one env map across tasks, so mutating it here (the - // HOME override below) would be a data race and leak across tasks. Clone, - // override on the copy, and leave the caller's map untouched. - var child_env = try env.clone(a); - defer child_env.deinit(); - - // xelatex/fontconfig need a writable HOME to write their caches. In the - // Nix build sandbox HOME is set to /homeless-shelter (read-only), which - // causes fontconfig to error and xelatex to exit non-zero. Override HOME - // with a directory under TMPDIR when the current value is not writable. - const home_ok = if (child_env.get("HOME")) |h| - (std.Io.Dir.accessAbsolute(io, h, .{}) catch null) != null - else - false; - if (!home_ok) { - const tmpdir = child_env.get("TMPDIR") orelse child_env.get("TMP") orelse "/tmp"; - const tmp_home = try std.fmt.allocPrint(a, "{s}/pp-home", .{tmpdir}); - defer a.free(tmp_home); - std.Io.Dir.cwd().createDirPath(io, tmp_home) catch {}; - try child_env.put("HOME", tmp_home); - panlog.debug("HOME not writable - overriding with {s}\n", .{tmp_home}); - } - - if (child_env.get("PATH")) |path| { - panlog.debug("Child PATH: {s}\n", .{path}); - } else { - panlog.debug("No PATH in env_map!\n", .{}); - } - - // Cap collected output so a runaway pandoc/filter cannot exhaust memory. - // Generous vs. the pre-0.16 100 KB cap since LaTeX errors are verbose; - // exceeding it returns error.StreamTooLong, matching the old behavior. - const max_output_bytes = 1024 * 1024; - const result = std.process.run(a, io, .{ - .argv = args.items, - .environ_map = &child_env, - .stdout_limit = .limited(max_output_bytes), - .stderr_limit = .limited(max_output_bytes), - }) catch |e| { - if (e == error.FileNotFound) { - std.debug.print( - "policypress: pandoc not found in PATH.\n" ++ - "Make sure you are running inside the PolicyPress devshell:\n\n" ++ - " nix develop github:sc2in/policypress\n\n", - .{}, - ); - return error.PandocNotFound; - } - if (e == error.StreamTooLong) { - std.debug.print( - "policypress: pandoc produced more than {d} bytes of output; aborting this policy.\n", - .{max_output_bytes}, - ); - return error.PandocFailed; - } - std.debug.print("policypress: failed to spawn pandoc: {s}\n", .{@errorName(e)}); - return e; - }; - defer a.free(result.stdout); - defer a.free(result.stderr); - - const exited_ok = switch (result.term) { - .exited => |code| code == 0, - else => false, - }; - - if (!exited_ok) { - // Print pandoc's stderr so the user can see the LaTeX/filter error. - if (result.stderr.len > 0) { - std.debug.print("policypress: pandoc error output:\n{s}\n", .{result.stderr}); - } - return error.PandocFailed; - } - - panlog.debug("{any} {s}\n", .{ result.term, result.stdout }); - if (result.stderr.len > 0) { - // Pandoc exited successfully but filters (e.g. mermaid-filter) wrote to - // stderr. Log at warn rather than err so the test runner doesn't mark the - // test as "logged errors" for expected sandbox noise. - panlog.warn("!!! {s}\n!!! Called with:\n", .{result.stderr}); - for (args.items) |arg| - panlog.warn("\t{s}\n", .{arg}); - } -} diff --git a/src/server.zig b/src/server.zig index f0003fa..2259836 100644 --- a/src/server.zig +++ b/src/server.zig @@ -34,7 +34,7 @@ pub fn main(init: std.process.Init) !void { defer res.deinit(); if (res.args.help != 0) { - std.debug.print("PolicyPress Dev Server\nSee Readme.md or run `devbox build docs` to learn more.\n\n", .{}); + std.debug.print("PolicyPress Dev Server\nSee Readme.md or run `nix run .#docs` to learn more.\n\n", .{}); return clap.help(&stderr, clap.Help, ¶ms, .{}); } const serve_dir = if (res.args.dir.len >= 1) res.args.dir[0] else "public"; diff --git a/src/test.zig b/src/test.zig index 818c075..3712c67 100644 --- a/src/test.zig +++ b/src/test.zig @@ -9,7 +9,7 @@ const math = std.math; const b = @import("builtin"); const config = @import("config").Config; -const pandoc = @import("pandoc"); +const typst = @import("typst"); const report = @import("reports"); const utils = @import("utils"); const zigmark = @import("zigmark"); @@ -47,7 +47,7 @@ fn tmpAbsPath(alloc: Allocator, tmp: *tst.TmpDir) ![]u8 { test { _ = utils; _ = zigmark; - _ = pandoc; + _ = typst; _ = report; tst.refAllDecls(@This()); } @@ -122,8 +122,7 @@ test "policy processing" { test "pdf rendering" { var env = try testEnvMap(tst.allocator); defer env.deinit(); - if (!pandoc.executableInPath(io, &env, "xelatex")) return error.SkipZigTest; - var args = Array([]u8).empty; + if (!utils.executableInPath(io, &env, "typst")) return error.SkipZigTest; var tmp = tst.tmpDir(.{}); @@ -143,14 +142,11 @@ test "pdf rendering" { conf.build_dir = try tmpAbsPath(alloc, &tmp); defer alloc.free(conf.build_dir); - try pandoc.create_global_args(io, &env, tst.allocator, &args, conf); - defer pandoc.destroy_global_args(tst.allocator, &args); - // Use a mermaid-free fixture so the test works in the Nix sandbox - // (Chrome/user-namespaces are unavailable there). Mermaid shortcode - // transformation is already covered by the "policy processing" test. - const md = utils.MDFile{ .path = "src/test/test_policy_render.md" }; - pandoc.process_md_file(io, &env, tst.allocator, md, args, conf) catch |e| { - std.debug.print("Test Policy Pandoc Call Failed! \nConfig:{f}\n", .{conf}); + // test_policy.md contains a mermaid diagram: unlike the pandoc pipeline + // (which needed Chrome for mermaid-filter), pozeiden renders it in-process + // so the full pipeline works even inside the Nix sandbox. + typst.compile(io, &env, tst.allocator, conf, "src/test/test_policy.md") catch |e| { + std.debug.print("Test Policy Typst Call Failed! \nConfig:{f}\n", .{conf}); return e; }; @@ -171,6 +167,37 @@ test "pdf rendering" { tmp.cleanup(); } +test "typst source: mermaid renders as inline svg via pozeiden" { + const alloc = tst.allocator; + var conf = try config.load(io, alloc, TestConfig); + defer conf.deinit(alloc); + + var rendered = try typst.render(io, alloc, conf, "src/test/test_policy.md"); + defer rendered.deinit(alloc); + + // The mermaid fenced block must become an embedded SVG image, not a + // leftover code block (which would mean pozeiden silently failed). + try tst.expect(std.mem.indexOf(u8, rendered.source, "bytes(\"