diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9574a98..bf2e6f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,19 +68,23 @@ jobs: run: nix --accept-flake-config profile add nixpkgs#omnix - name: Configure permissions - # Enable unprivileged user namespaces required by Chromium/mermaid-filter - # on Linux. The sysctl does not exist on macOS so || true makes it safe. + # Enable unprivileged user namespaces, required by bubblewrap: zig2nix's + # zig-hook runs the compiler inside bwrap for network isolation, so any + # from-source build of zig2nix tooling fails without this on Ubuntu + # 24.04 runners ("bwrap: setting up uid map: Permission denied"). + # The sysctl does not exist on macOS so || true makes it safe. run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true - name: Run CI run: | if [[ "$RUNNER_OS" == "macOS" ]]; then - # mermaid-filter/Chromium not available on macOS; run semantic - # analysis only until a macOS-compatible renderer is available. - # Use just the pinned Zig (prebuilt) rather than the full devShell: - # zig2nix's source-built inputs currently pull glibc into the - # aarch64-darwin closure, which nix refuses to evaluate. - nix shell .#zig --command zig build check + # Full test suite on macOS: the typst engine renders mermaid + # in-process (pozeiden), so the old mermaid-filter/Chromium + # limitation is gone. Use just the pinned Zig (prebuilt) plus + # typst rather than the full devShell: zig2nix's source-built + # inputs currently pull glibc into the aarch64-darwin closure, + # which nix refuses to evaluate. + nix shell .#zig nixpkgs#typst --command zig build test else om ci run fi diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 401bd31..158ec2b 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -16,7 +16,9 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Configure unprivileged user namespaces + - name: Configure permissions + # Unprivileged user namespaces for bubblewrap (zig2nix's zig-hook); + # see the comment in ci.yml. run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true - name: Build site and PDFs diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 2e97e4a..1c8896d 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -19,7 +19,9 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Configure unprivileged user namespaces + - name: Configure permissions + # Unprivileged user namespaces for bubblewrap (zig2nix's zig-hook); + # see the comment in ci.yml. run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true - name: Compute preview URL 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/CHANGELOG.md b/CHANGELOG.md index 30cfac5..3959cc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,47 @@ keys) is considered stable. ## [Unreleased] +### Changed + +- **PDF engine migrated from pandoc/XeLaTeX/Eisvogel to Typst** (#58). Markdown + is rendered to Typst markup in-process by zigmark and compiled by the `typst` + CLI; the layout (title page, colored rule, logo, headers/footers, zebra + tables, TOC, version-history table) reproduces the Eisvogel look. CLI flags, + config options, and output filename patterns are unchanged. +- The GitHub Action's build shell is now pinned to the action's own version + (previously it floated on `main`, so flake changes could break consumers + running older release binaries). +- `-v/--verbose` now shows typst invocations instead of pandoc arguments. + +### Added + +- **Native mermaid rendering via pozeiden** - diagrams compile to inline SVG + in-process with no browser, Node.js, or Chromium. Diagram-bearing policies + now build inside the Nix sandbox and on macOS, and diagrams render at their + natural size (capped at the text width) instead of being scaled to full page + width. +- Full test suite now runs on macOS CI (previously only semantic analysis, + blocked by mermaid-filter's Chromium dependency). + +### Fixed + +- `redact_web` no longer drives PDF redaction (#115). It was documented as + web-only but seeded the PDF pipeline's redact default, so an org hiding + content (e.g. phone numbers) on the website could not keep it in the PDFs + without passing `--no-redact`. PDF redaction is now controlled solely by + `--redact`/`--no-redact` and the action's `redact_mode` input (which always + passes an explicit flag, so action consumers are unaffected). If you ran + the bare CLI and relied on `redact_web = true` redacting PDFs, pass + `--redact` explicitly. + +### Removed + +- pandoc, texlive/eisvogel-tex, and mermaid-filter (Node.js + Puppeteer + + Chromium) from the toolchain, along with the `MERMAID_FILTER_CMD_MMDC` + sandbox workaround, fontconfig plumbing, and the embedded `eisvogel.latex` + template. The Nix CI closure shrinks from multiple GB to a few hundred MB. +- `Config.data_dir` (was only used to stage the LaTeX template). + ## [1.3.0] - 2026-07-02 ### Changed diff --git a/README.md b/README.md index 04bb48a..eed4a88 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A compliance policy management platform for small and mid-size businesses. Write policies in Markdown, version them in Git, publish a branded static site, and generate audit-ready PDFs — all from a single GitHub Action. -PolicyPress is built on [Zola](https://www.getzola.org/) and [Pandoc](https://pandoc.org/). It is designed to be hosted by your customers in their own repositories; PolicyPress itself is the theme and toolchain, not the content. +PolicyPress is built on [Zola](https://www.getzola.org/) and [Typst](https://typst.app/). It is designed to be hosted by your customers in their own repositories; PolicyPress itself is the theme and toolchain, not the content. ## Who this is for @@ -89,7 +89,7 @@ Internal notes - stripped from redacted PDFs. PDFs are named `{Title}_-_v{version}.pdf`. With `redact_mode: true`, the name becomes `{Title}_(Redacted)_-_v{version}.pdf`. With `draft_mode: true`, it becomes `{Title}_(Draft)_-_v{version}.pdf`. -PDFs are generated using the [Eisvogel](https://github.com/Wandmalfarbe/pandoc-latex-template) Pandoc LaTeX template via XeLaTeX. +PDFs are generated with [Typst](https://typst.app/): markdown is rendered to Typst markup in-process ([zigmark](https://github.com/sc2in/zigmark)), mermaid diagrams become inline SVG via [pozeiden](https://github.com/sc2in/pozeiden), and the layout matches the classic [Eisvogel](https://github.com/Wandmalfarbe/pandoc-latex-template) look. The only external tool is the `typst` binary (bundled in the devshell and GitHub Action; on Windows install it with `winget install Typst.Typst` - without the Source Sans 3 font typst falls back to its embedded fonts, a cosmetic difference only). ## Compliance reports @@ -119,7 +119,7 @@ nix run github:sc2in/policypress -- -c config.toml -o public # Generate redacted PDFs nix run github:sc2in/policypress -- -c config.toml -o public/redacted --redact -# Verbose output (shows pandoc args) +# Verbose output (shows typst invocations) nix run github:sc2in/policypress -- -v -c config.toml -o public # CI-friendly JSON log output @@ -141,9 +141,9 @@ zig build test | Dependency | Purpose | | --- | --- | | [Zola](https://www.getzola.org/) | Static site generator | -| [Pandoc](https://pandoc.org/) | PDF generation | -| [Eisvogel](https://github.com/Wandmalfarbe/pandoc-latex-template) | PDF template | -| [zigmark](https://github.com/sc2in/zigmark) | YAML/TOML frontmatter parsing | +| [Typst](https://typst.app/) | PDF compilation | +| [zigmark](https://github.com/sc2in/zigmark) | Markdown parsing + Typst rendering | +| [pozeiden](https://github.com/sc2in/pozeiden) | Mermaid diagrams to SVG | | [tomlz](https://github.com/tsunaminoai/tomlz) | TOML config parsing | | [clap](https://github.com/Hejsil/zig-clap) | CLI argument parsing | | [mvzr](https://github.com/mnemnion/mvzr) | Regex for markdown transforms | @@ -166,7 +166,8 @@ PolicyPress is developed and maintained by [Star City Security Consulting, LLC ( **Built on:** - [Zola](https://www.getzola.org/) - static site generator -- [Eisvogel](https://github.com/Wandmalfarbe/pandoc-latex-template) - PDF template by Pascal Wagler +- [Typst](https://typst.app/) - PDF typesetting +- [Eisvogel](https://github.com/Wandmalfarbe/pandoc-latex-template) by Pascal Wagler - the PDF layout PolicyPress reproduces - [Secure Controls Framework (SCF)](https://securecontrolsframework.com/) - control taxonomy - [AICPA Trust Services Criteria (TSC)](https://www.aicpa-cima.com/resources/landing/2017-trust-services-criteria) - SOC 2 control framework diff --git a/action.yml b/action.yml index dadd4aa..236359d 100644 --- a/action.yml +++ b/action.yml @@ -76,7 +76,10 @@ runs: uses: actions/checkout@v4 with: repository: sc2in/policypress - ref: ${{ steps.get-ref.outputs.ref }} + # action_ref for remote consumers (uses: sc2in/policypress@vX); for + # in-repo `uses: ./` runs it is empty, so fall back to the workflow's + # own commit — previews then use the PR's theme, not main's. + ref: ${{ steps.get-ref.outputs.ref || github.sha }} path: themes/policypress - name: Install policypress binary @@ -84,36 +87,38 @@ runs: env: GH_TOKEN: ${{ github.token }} run: | - case "$(uname -s)-$(uname -m)" in - Linux-x86_64) BINARY="policypress-x86_64-linux" ;; - Linux-aarch64) BINARY="policypress-aarch64-linux" ;; - Darwin-arm64) BINARY="policypress-aarch64-macos" ;; - Darwin-x86_64) BINARY="policypress-x86_64-macos" ;; - *) - echo "Unsupported platform: $(uname -s)-$(uname -m)" - exit 1 - ;; - esac - mkdir -p "${RUNNER_TEMP}/policypress-bin" - gh release download \ - --repo sc2in/policypress \ - --pattern "${BINARY}" \ - --dir "${RUNNER_TEMP}/policypress-bin" - mv "${RUNNER_TEMP}/policypress-bin/${BINARY}" \ - "${RUNNER_TEMP}/policypress-bin/policypress" - chmod +x "${RUNNER_TEMP}/policypress-bin/policypress" - echo "${RUNNER_TEMP}/policypress-bin" >> "${GITHUB_PATH}" - - name: Copy pandoc templates - shell: bash - run: | - # pandoc looks for eisvogel.latex in {data-dir}/templates/ (where - # data-dir defaults to the site root). Copy it from the theme so - # consumers don't need to vendor it. - mkdir -p templates - cp -n themes/policypress/templates/eisvogel.latex templates/ - cp -n themes/policypress/templates/eisvogel.pdf templates/ 2>/dev/null || true + if [ -z "${{ steps.get-ref.outputs.ref }}" ]; then + # Running via `uses: ./` from a checkout of this repo (e.g. the PR + # preview workflow). Build the binary from the action's own flake so + # previews exercise the code under review — the latest release binary + # may not even match this branch's toolchain. + echo "No action ref (local action) - building policypress from the flake…" + out="$(nix build "path:${{ github.action_path }}#policypress-safe" --print-out-paths --no-link)" + install -m755 "${out}/bin/policypress" "${RUNNER_TEMP}/policypress-bin/policypress" + else + case "$(uname -s)-$(uname -m)" in + Linux-x86_64) BINARY="policypress-x86_64-linux" ;; + Linux-aarch64) BINARY="policypress-aarch64-linux" ;; + Darwin-arm64) BINARY="policypress-aarch64-macos" ;; + Darwin-x86_64) BINARY="policypress-x86_64-macos" ;; + *) + echo "Unsupported platform: $(uname -s)-$(uname -m)" + exit 1 + ;; + esac + + gh release download \ + --repo sc2in/policypress \ + --pattern "${BINARY}" \ + --dir "${RUNNER_TEMP}/policypress-bin" + mv "${RUNNER_TEMP}/policypress-bin/${BINARY}" \ + "${RUNNER_TEMP}/policypress-bin/policypress" + chmod +x "${RUNNER_TEMP}/policypress-bin/policypress" + fi + + echo "${RUNNER_TEMP}/policypress-bin" >> "${GITHUB_PATH}" - name: Copy theme shortcodes shell: bash @@ -146,7 +151,10 @@ runs: - name: Build site and PDFs id: build - shell: nix develop "github:sc2in/policypress#ci" --command bash -e {0} + # The ci shell comes from the action's own checkout, so the toolchain + # (typst, zola, fonts) always matches the action version in use instead + # of floating on main. Also works for in-repo `uses: ./` workflows. + shell: nix develop "path:${{ github.action_path }}#ci" --command bash -e {0} run: | set -euo pipefail 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 e7e5e58..a07239d 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?ref=v0.7.4#b82e5ed2c993e5360c15a3e51732fe76e0712727", + .hash = "zigmark-0.7.4-cwOiyEMyCgBy834ZlSIgdMG2lvWRis3kbSrC4pSpkZ1h", }, .tomlz = .{ .url = "git+https://github.com/sc2in/tomlz.git#ecd64e239768b573ec58286d0db1842991433e99", @@ -61,12 +61,15 @@ .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?ref=v0.2.0#9d44249023a41d4191685023d47a9e2076b99c43", + .hash = "pozeiden-0.2.0-NAqiXaypCgBCkAMB3WZpGLPnwV5XTsUvOf3KUY9aAN0p", + }, }, .paths = .{ "build.zig", "build.zig.zon", "src", - "templates/eisvogel.latex", "LICENSE", "README.md", "SECURITY.md", diff --git a/build.zig.zon2json-lock b/build.zig.zon2json-lock index 96384c4..c850c21 100644 --- a/build.zig.zon2json-lock +++ b/build.zig.zon2json-lock @@ -1,8 +1,8 @@ { - "zigmark-0.7.0-cwOiyN4KCgAHRBp6kiQE2fbckRQiOUfPQNq9rYPtOrqG": { + "zigmark-0.7.4-cwOiyEMyCgBy834ZlSIgdMG2lvWRis3kbSrC4pSpkZ1h": { "name": "zigmark", - "url": "git+https://github.com/sc2in/zigmark#e590c0a1d707744811d936b038d4a7e0780b32e9", - "hash": "sha256-Z8sTn+5PYYlqxcXEjKl4BShYtV4RDwvgKwn/2ErfuGE=" + "url": "git+https://github.com/sc2in/zigmark?ref=v0.7.4#b82e5ed2c993e5360c15a3e51732fe76e0712727", + "hash": "sha256-/4ABWZ7ioX9+MGWmsLpTPoNTjbNwRyLbjKHbI0JQJz0=" }, "tomlz-0.3.0-H2E6w3VKAgCSZQFKG-VugLEn3cjOWshGqwGzAVABNm--": { "name": "tomlz", @@ -34,10 +34,10 @@ "url": "git+https://github.com/github/cmark-gfm.git#587a12bb54d95ac37241377e6ddc93ea0e45439b", "hash": "sha256-egCWzw09e5daT8cnlSORC3gep9soiqonVC4GQ/s3voM=" }, - "pozeiden-0.1.3-NAqiXeOXCgAUNGkydN-lIYUZQ87uzGQPOQ-wXvK4FNW3": { + "pozeiden-0.2.0-NAqiXaypCgBCkAMB3WZpGLPnwV5XTsUvOf3KUY9aAN0p": { "name": "pozeiden", - "url": "https://github.com/sc2in/pozeiden/archive/refs/tags/v0.1.3.tar.gz", - "hash": "sha256-D6MZywks5n7jCWx/C02HT8LSdaWleCsL2/jjpYAbM1Q=" + "url": "https://github.com/sc2in/pozeiden/archive/refs/tags/v0.2.0.tar.gz", + "hash": "sha256-9j8xkNndJX+l20+/qlfK2aLFCfPQsX1xoTDQ+V2jXUg=" }, "mvzr-0.3.9-ZSOky8FzAQBQ9-GkQnaLjOZZHxrioD8NwY-QyZT6oAyR": { "name": "mvzr", diff --git a/content/guides/building-pdfs.md b/content/guides/building-pdfs.md index 3baf20b..bf28331 100644 --- a/content/guides/building-pdfs.md +++ b/content/guides/building-pdfs.md @@ -42,7 +42,7 @@ Acceptable_Use_Policy_(Draft)_-_v1.2.pdf ## Technical details -PDFs are generated by [Pandoc](https://pandoc.org/) using the [Eisvogel](https://github.com/Wandmalfarbe/pandoc-latex-template) LaTeX template and XeLaTeX. Diagrams written in Mermaid are rendered via `mermaid-filter` before passing to Pandoc. +PDFs are generated by [Typst](https://typst.app/). PolicyPress parses the markdown in-process with [zigmark](https://github.com/sc2in/zigmark) and emits Typst markup styled after the classic [Eisvogel](https://github.com/Wandmalfarbe/pandoc-latex-template) template. Mermaid diagrams are rendered to SVG in-process by [pozeiden](https://github.com/sc2in/pozeiden) - no browser or JavaScript runtime is involved, so diagram builds are fast and work on every platform. ## Local PDF generation diff --git a/content/guides/installation.md b/content/guides/installation.md index 171aa85..312f67b 100644 --- a/content/guides/installation.md +++ b/content/guides/installation.md @@ -48,9 +48,6 @@ git submodule update --init Zola does not automatically propagate certain files from a theme into the build. After adding the submodule, copy these assets once into your repository and commit them: ```bash -mkdir -p templates -cp -n themes/policypress/templates/eisvogel.latex templates/ - mkdir -p templates/shortcodes cp -n themes/policypress/templates/shortcodes/*.html templates/shortcodes/ @@ -164,7 +161,7 @@ variables: ${{ else }}: publish: 'false' # Pin to a specific release. Update this when you want to upgrade policypress. - POLICYPRESS_VERSION: 'v1.2.6' + POLICYPRESS_VERSION: 'v1.4.0' pool: vmImage: ubuntu-latest @@ -230,7 +227,7 @@ stages: Link it to a pipeline in **Azure DevOps → Pipelines → New pipeline** and point it at this file. Store the Azure Static Web Apps deployment token as a pipeline secret variable named `deployment_token`. > [!NOTE] -> The first run downloads the Nix CI environment (~1–2 GB for Zola, Pandoc, and fonts), which takes a few minutes. The policypress binary itself is fetched as a pre-compiled release artifact, so no Zig compilation is needed. To upgrade policypress, update the `POLICYPRESS_VERSION` variable. For additional Nix store caching between runs, see [Determinate Systems FlakeHub Cache](https://docs.determinate.systems/flakehub-cache/). +> The first run downloads the Nix CI environment (a few hundred MB for Zola, Typst, and fonts), which takes a minute or two. The policypress binary itself is fetched as a pre-compiled release artifact, so no Zig compilation is needed. To upgrade policypress, update the `POLICYPRESS_VERSION` variable. For additional Nix store caching between runs, see [Determinate Systems FlakeHub Cache](https://docs.determinate.systems/flakehub-cache/). diff --git a/content/guides/live-editing.md b/content/guides/live-editing.md index b762a80..02a4fc6 100644 --- a/content/guides/live-editing.md +++ b/content/guides/live-editing.md @@ -34,7 +34,7 @@ You can refer to [Adding a Policy](@/guides/adding-a-policy.md#front-matter) for ### Saving Changes -After making changes to the policy, save the file. If you are running `devbox run serve`, the changes will be automatically reflected in the local web server. +After making changes to the policy, save the file. If you are running `nix run .#serve`, the changes will be automatically reflected in the local web server. ## Committing Changes diff --git a/content/guides/writing-policies.md b/content/guides/writing-policies.md index d495f86..cd40dd3 100644 --- a/content/guides/writing-policies.md +++ b/content/guides/writing-policies.md @@ -117,7 +117,7 @@ graph TD Supported types include `graph`, `sequenceDiagram`, `flowchart`, `classDiagram`, and `gantt`. See [Mermaid docs](https://mermaid.js.org/intro/) for the full syntax. > [!NOTE] -> Mermaid diagrams require `mermaid-filter` at build time. It's included in the PolicyPress devshell and GitHub Action automatically. +> Mermaid diagrams are rendered natively by PolicyPress (via [pozeiden](https://github.com/sc2in/pozeiden)) - no extra tooling is required, and they work on every platform including macOS and Windows. ### `{%/* admonition(type="...") */%} ... {%/* end */%}` - callout boxes diff --git a/flake.lock b/flake.lock index a892ab9..46bdda3 100644 --- a/flake.lock +++ b/flake.lock @@ -1,24 +1,5 @@ { "nodes": { - "eisvogel-tex": { - "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" - }, - "locked": { - "lastModified": 1768752806, - "narHash": "sha256-T5i8ya61+w1y8JG2nRL1QhDJm723ssgCtnpnK1i9IGs=", - "owner": "sc2in", - "repo": "eisvogel-tex", - "rev": "63c115d0732153d06c990e88f895e8568c4aae1f", - "type": "github" - }, - "original": { - "owner": "sc2in", - "repo": "eisvogel-tex", - "type": "github" - } - }, "flake-compat": { "flake": false, "locked": { @@ -71,29 +52,11 @@ "type": "github" } }, - "flake-utils_2": { - "inputs": { - "systems": "systems_2" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "git-hooks": { "inputs": { "flake-compat": "flake-compat", "gitignore": "gitignore", - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs" }, "locked": { "lastModified": 1775036584, @@ -132,16 +95,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1763049705, - "narHash": "sha256-A5LS0AJZ1yDPTa2fHxufZN++n8MCmtgrJDtxFxrH4S8=", - "owner": "nixos", + "lastModified": 1770073757, + "narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=", + "owner": "NixOS", "repo": "nixpkgs", - "rev": "3acb677ea67d4c6218f33de0db0955f116b7588c", + "rev": "47472570b1e607482890801aeaf29bfb749884f6", "type": "github" }, "original": { - "owner": "nixos", - "ref": "nixos-25.05", + "owner": "NixOS", + "ref": "nixpkgs-unstable", "repo": "nixpkgs", "type": "github" } @@ -162,22 +125,6 @@ } }, "nixpkgs_2": { - "locked": { - "lastModified": 1770073757, - "narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "47472570b1e607482890801aeaf29bfb749884f6", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_3": { "locked": { "lastModified": 1775036866, "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", @@ -191,7 +138,7 @@ "url": "https://flakehub.com/f/NixOS/nixpkgs/0.1.%2A.tar.gz" } }, - "nixpkgs_4": { + "nixpkgs_3": { "locked": { "lastModified": 1770107345, "narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=", @@ -207,7 +154,7 @@ "type": "github" } }, - "nixpkgs_5": { + "nixpkgs_4": { "locked": { "lastModified": 1782872117, "narHash": "sha256-1OTpVD4z4KsG+WPH8O2BshANoWyCaaUCYf3ZMhslsHI=", @@ -224,10 +171,9 @@ }, "root": { "inputs": { - "eisvogel-tex": "eisvogel-tex", "flake-parts": "flake-parts", "git-hooks": "git-hooks", - "nixpkgs": "nixpkgs_3", + "nixpkgs": "nixpkgs_2", "treefmt-nix": "treefmt-nix", "zig2nix": "zig2nix" } @@ -247,24 +193,9 @@ "type": "github" } }, - "systems_2": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, "treefmt-nix": { "inputs": { - "nixpkgs": "nixpkgs_4" + "nixpkgs": "nixpkgs_3" }, "locked": { "lastModified": 1775125835, @@ -282,8 +213,8 @@ }, "zig2nix": { "inputs": { - "flake-utils": "flake-utils_2", - "nixpkgs": "nixpkgs_5" + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs_4" }, "locked": { "lastModified": 1782791722, diff --git a/flake.nix b/flake.nix index 121466f..71be001 100644 --- a/flake.nix +++ b/flake.nix @@ -4,7 +4,6 @@ inputs = { nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1.*.tar.gz"; zig2nix.url = "https://flakehub.com/f/Cloudef/zig2nix/0.1.*.tar.gz"; - eisvogel-tex.url = "github:sc2in/eisvogel-tex"; flake-parts.url = "github:hercules-ci/flake-parts"; git-hooks.url = "github:cachix/git-hooks.nix"; treefmt-nix.url = "github:numtide/treefmt-nix"; @@ -15,7 +14,6 @@ self, nixpkgs, zig2nix, - eisvogel-tex, flake-parts, git-hooks, treefmt-nix, @@ -67,10 +65,9 @@ ./build.zig ./build.zig.zon ./src - ./templates - # logo.png and draft.png are referenced at test time by xelatex - # (via the eisvogel template). Including them here avoids a - # "unable to load picture" error in the pdf rendering test. + # logo.png and draft.png are referenced at test time by the + # typst pdf-rendering tests (header/title-page logo and the + # draft watermark background). ./static/logo.png ./static/draft.png ] @@ -95,22 +92,21 @@ ); }; - fontsConf = pkgs.makeFontsConf { - fontDirectories = [ + # Fonts for typst (Source Sans 3 body, Source Code Pro mono). + # TYPST_FONT_PATHS is walked recursively by typst-cli; typst falls + # back to its embedded fonts when a family is missing. + typstFonts = pkgs.symlinkJoin { + name = "policypress-typst-fonts"; + paths = [ pkgs.source-sans pkgs.source-code-pro ]; }; - runtimeDeps = - with pkgs; - [ - pandoc - zola - imagemagick - eisvogel-tex.packages.${system}.default - ] - ++ lib.optional (system != "aarch64-darwin") pkgs.mermaid-filter; + runtimeDeps = with pkgs; [ + typst + zola + ]; withDesc = drv: desc: @@ -135,58 +131,6 @@ ); policypress = mkPolicypress "ReleaseSafe"; - - # mermaid-filter calls its bundled mmdc via an absolute Nix store path, - # bypassing PATH. MERMAID_FILTER_CMD_MMDC overrides that path with our - # wrapper, which runs at pandoc-filter invocation time (so $TMPDIR is the - # real, writable Nix-build temp dir) and: - # 1. Creates a writable user-data-dir for Chrome under $TMPDIR - # 2. Writes a fresh puppeteer JSON config pointing there - # 3. Strips any existing -p flag forwarded from mermaid-filter and - # replaces it with our own so Chrome gets --no-sandbox + a valid dir - # mmdcWrapper is only usable on Linux — chromium is not packaged for macOS. - # On macOS the wrapper is a no-op stub so derivations that reference it - # still evaluate without errors; the pdf-rendering tests are skipped via - # the CI workflow instead of running `om ci run` on macOS. - mmdcWrapper = - if pkgs.stdenv.isLinux then - let - realMmdc = "${pkgs.mermaid-filter}/lib/node_modules/mermaid-filter/node_modules/.bin/mmdc"; - in - pkgs.writeShellApplication { - name = "mmdc"; - runtimeInputs = [ pkgs.chromium ]; - text = '' - chrome_userdata=$(mktemp -d) - puppeteer_cfg=$(mktemp --suffix=.json) - trap 'rm -rf "$chrome_userdata" "$puppeteer_cfg"' EXIT - - printf '{"executablePath":"%s/bin/chromium","userDataDir":"%s","args":["--no-sandbox","--disable-setuid-sandbox","--disable-dev-shm-usage","--disable-gpu","--no-zygote"]}' \ - "${pkgs.chromium}" "$chrome_userdata" > "$puppeteer_cfg" - - # Strip any existing -p / --puppeteerConfigFile arg forwarded by - # mermaid-filter (it comes in as: -p /nix/store/...-puppeteer-config.json) - args=() - skip=false - for arg in "$@"; do - if $skip; then skip=false; continue; fi - if [[ "$arg" == "-p" || "$arg" == "--puppeteerConfigFile" ]]; then - skip=true; continue - fi - args+=("$arg") - done - - exec ${realMmdc} -p "$puppeteer_cfg" "''${args[@]}" - ''; - } - else - pkgs.writeShellApplication { - name = "mmdc"; - runtimeInputs = [ ]; - text = '' - echo "mermaid diagrams are not supported on this platform, skipping" >&2 - ''; - }; in { # --- Formatting (nix fmt) ------------------------------------------- @@ -263,20 +207,18 @@ checks.formatting = config.treefmt.build.check self; checks.test = - # MERMAID_FILTER_CMD_MMDC overrides the absolute-store-path mmdc binary - # that mermaid-filter would otherwise call, letting our wrapper create - # writable Chrome user-data and crashpad dirs under $TMPDIR at runtime. + # The pdf-rendering tests spawn `typst compile` in the sandbox; + # mermaid diagrams render in-process via pozeiden (pure Zig), so + # no Chromium or fontconfig plumbing is needed anymore. (mkPolicypress null).overrideAttrs (old: { pname = "policypress-test"; buildPhase = "zig build test"; installPhase = "touch $out"; - nativeBuildInputs = - (old.nativeBuildInputs or [ ]) - ++ runtimeDeps - ++ lib.optionals pkgs.stdenv.isLinux [ pkgs.chromium ] - ++ [ mmdcWrapper ]; - FONTCONFIG_FILE = fontsConf; - MERMAID_FILTER_CMD_MMDC = "${mmdcWrapper}/bin/mmdc"; + nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ runtimeDeps; + TYPST_FONT_PATHS = "${typstFonts}/share/fonts"; + # Deterministic font resolution in and out of the sandbox + # (typst accepts only "true"/"false" here). + TYPST_IGNORE_SYSTEM_FONTS = "true"; meta = (old.meta or { }) // { description = "Run zig build test"; }; @@ -306,7 +248,7 @@ name = "policypress"; runtimeInputs = [ policypress ] ++ runtimeDeps; text = '' - export FONTCONFIG_FILE="${fontsConf}" + export TYPST_FONT_PATHS="${typstFonts}/share/fonts" exec policypress "$@" ''; }; @@ -329,13 +271,12 @@ ] ++ runtimeDeps; text = '' - export FONTCONFIG_FILE="${fontsConf}" + export TYPST_FONT_PATHS="${typstFonts}/share/fonts" mkdir -p static/pdfs # Run regular and draft compilations in parallel on startup. - # The PID-prefixed temp filenames in policypress prevent the two - # processes from colliding when writing preprocessed markdown to - # static/pdfs before pandoc picks it up. + # The random-suffixed temp filenames in policypress prevent the + # two processes from colliding on intermediate .typ sources. policypress -o static/pdfs & policypress -o static/pdfs --draft & wait @@ -367,7 +308,7 @@ ] ++ runtimeDeps; text = '' - export FONTCONFIG_FILE="${fontsConf}" + export TYPST_FONT_PATHS="${typstFonts}/share/fonts" zola build --base-url "http://0.0.0.0:1111" policypress -o static/pdfs policypress -o static/pdfs --draft @@ -576,8 +517,7 @@ devShells.ci = pkgs.mkShell { buildInputs = runtimeDeps; shellHook = '' - export FONTCONFIG_FILE="${fontsConf}" - mkdir -p "$HOME/.cache/fontconfig" + export TYPST_FONT_PATHS="${typstFonts}/share/fonts" ''; }; @@ -591,7 +531,6 @@ pkgs.act pkgs.omnix pkgs.watchexec - pkgs.typst zig2nix.outputs.packages.${system}."zls-0_16_0" (pkgs.writeShellScriptBin "update-zon" '' set -euo pipefail @@ -604,7 +543,7 @@ ]; shellHook = config.pre-commit.installationScript + '' - export FONTCONFIG_FILE="${fontsConf}" + export TYPST_FONT_PATHS="${typstFonts}/share/fonts" export ZIG_GLOBAL_CACHE_DIR=.zig-cache # Install the release-tag CHANGELOG guard. The pre-commit diff --git a/src/config.zig b/src/config.zig index a1b8489..f951b89 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); @@ -130,7 +126,12 @@ pub const Config = struct { config.org = e.getString("organization").?; config.build_dir = "public"; config.zola_config = t; - config.redact = e.getBool("redact_web") orelse false; + // PDF redaction is controlled only by --redact/--no-redact (and the + // action's redact_mode input). `redact_web` is read by the Zola + // templates for the website's redaction bars and deliberately does + // not affect PDFs: an org may hide content (e.g. phone numbers) on + // the public site while keeping it in the PDFs. (#115) + config.redact = false; return config; } pub fn deinit(self: *Config, alloc: Allocator) void { diff --git a/src/main.zig b/src/main.zig index 8a86b83..4ef6c08 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,9 @@ 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.TypstWorkFileConflict => "could not create a unique .typ work file at the site root", + 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 +521,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..d440536 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()); } @@ -56,6 +56,10 @@ test "config loading and validation" { const alloc = tst.allocator; var conf = try config.load(io, alloc, TestConfig); defer conf.deinit(alloc); + // redact_web is web-only (Zola templates); PDF redaction comes solely + // from --redact/--no-redact. TestConfig sets redact_web = true, so this + // pins the decoupling (#115). + try tst.expect(!conf.redact); alloc.free(conf.content_dir); conf.content_dir = try std.fs.path.join(alloc, &.{ conf.root, @@ -122,8 +126,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 +146,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 +171,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(\"