Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .github/workflows/deploy-site.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Deploy site

# Only the landing page's own sources — including the pure cover core the wasm
# hero depends on. A GUI-only change (main.rs, ui/) does not trigger a redeploy.
on:
push:
branches: [main]
paths:
- "web/**"
- "examples/site.rs"
- "examples/build_site.rs"
- "src/wasm.rs"
- "src/lib.rs"
- "src/cover/**"
- "src/design/**"
- "assets/fonts/**"
- "assets/icons/icon.svg"
- "Cargo.toml"
- ".github/workflows/deploy-site.yml"
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

env:
CARGO_TERM_COLOR: always

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown

- uses: Swatinem/rust-cache@v2

- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh

- name: Build wasm hero engine
run: >-
wasm-pack build --target web --release
--out-dir web/wasm --out-name acag
-- --no-default-features

- name: Generate images, icons, and webfonts (acag engine)
run: cargo run --release --example site --no-default-features --features render

- name: Assemble dist/
run: cargo run --release --example build_site --no-default-features

- uses: actions/configure-pages@v5
with:
enablement: true

- uses: actions/upload-pages-artifact@v3
with:
path: dist

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/target
**/*.rs.bk
*.pdb
/dist
27 changes: 23 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,24 @@ src/
raster.rs SVG → Pixmap/PNG by longest edge (resvg + embedded Montserrat)
export.rs save SVG / PNG (2K or 4K)
preset.rs save/load named presets as TOML
wasm.rs WebAssembly surface for the landing page (pure part tested natively)
main.rs Slint GUI wiring (background export + spinner)
ui/app.slint the editor + live preview
examples/ gallery.rs (docs/samples), icon.rs (app icon + icon.ico)
build.rs compiles the Slint UI; embeds the Windows icon resource
web/ the GitHub Pages landing (index.html · css · js · generated assets)
examples/ gallery.rs (docs/samples), icon.rs (app icon + icon.ico),
site.rs (web/assets), build_site.rs (web/ → dist/)
build.rs compiles the Slint UI (gui only); embeds the Windows icon resource
```

The library crate holds all logic and is what tests cover; `main.rs` is thin GUI
glue. Fonts (Montserrat Black/Bold/Regular) are embedded via `include_bytes!`, so
no system fonts are needed at runtime.

Cargo features mirror hypso's layering: `gui` (default) ⊃ `render` (resvg + file
I/O, no Slint) ⊃ the bare pure core (`cover` + `design` + `wasm`), which is what
`wasm-pack` compiles for the landing page. `export`/`preset`/`raster` only exist
under `render`; the `acag` binary requires `gui`.

## Commands

```sh
Expand All @@ -46,6 +54,11 @@ cargo clippy --all-targets -- -D warnings # lint gate
cargo llvm-cov --lib --fail-under-lines 80 # coverage gate (library ≥ 80%)
cargo run --example gallery # regenerate docs/samples
cargo run --example icon # regenerate assets/icons/* (PNGs + icon.ico)

# Landing page (web/ → GitHub Pages)
wasm-pack build --target web --release --out-dir web/wasm --out-name acag -- --no-default-features
cargo run --example site --no-default-features --features render # web/assets (og, poster, icons, fonts)
cargo run --example build_site --no-default-features # assemble dist/
```

CI (`.github/workflows/ci.yml`) runs fmt, clippy `-D warnings`, `cargo test`, and
Expand Down Expand Up @@ -81,13 +94,19 @@ change is done.

`.github/workflows/release.yml` triggers on `v*` tags: builds a Linux AppImage +
tarball and a standalone Windows `.exe` (static CRT), attached to the GitHub
release. The AppImage `.desktop` `Categories` must use only freedesktop-registered
release. `.github/workflows/deploy-site.yml` publishes the landing page to
GitHub Pages on pushes to `main` that touch the site or the pure core: wasm-pack
build → `site` example → `build_site` example → deploy `dist/`. The AppImage `.desktop` `Categories` must use only freedesktop-registered
values (no unregistered `Design`, etc.) or `appimagetool` fails.

## Gotchas

- Keep preview and export pixel-identical: only the rasterization size differs.
- Regenerate `docs/samples` (`gallery`) and the icon set (`icon`) when their
inputs change; both are committed assets.
inputs change; both are committed assets. The same goes for `web/assets`
(`site` example); only `web/wasm/` is CI-built and gitignored.
- `web/js/main.js` mirrors `poster_config()` from `examples/site.rs` as
`FIRST_PLATE`/`FIRST_STYLE`; keep them in sync or the poster → live swap
flickers.
- After editing `ui/app.slint`, the build re-runs `slint-build`; mismatched
property names surface as compile errors in `main.rs`.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 46 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,62 @@ license = "MIT"
repository = "https://github.com/skvggor/acag"
readme = "README.md"

[lib]
# `cdylib` is the WebAssembly artifact for the landing-page hero; `rlib` keeps
# the binary, tests, and examples linking against the native library.
crate-type = ["cdylib", "rlib"]

[[bin]]
name = "acag"
path = "src/main.rs"
required-features = ["gui"]

[features]
default = ["gui"]
# `render`: rasterization + file I/O (resvg, no GUI). Enough for the asset
# examples (`gallery`, `icon`, `site`) — and it never pulls in Slint.
render = ["dep:anyhow", "dep:dirs", "dep:resvg", "dep:toml"]
# `gui`: the full desktop app on top of `render` (Slint + the Slint compiler).
gui = ["render", "dep:slint", "dep:slint-build"]
# Disabling both leaves only the pure cover core (design → typeset → render SVG)
# plus the `wasm` binding — what the browser build compiles.

[dependencies]
anyhow = "1.0.102"
dirs = "6.0.0"
anyhow = { version = "1.0.102", optional = true }
dirs = { version = "6.0.0", optional = true }
fastrand = "2.4.1"
resvg = "0.47.0"
resvg = { version = "0.47.0", optional = true }
serde = { version = "1.0.228", features = ["derive"] }
slint = { version = "1.16.1", features = ["renderer-software"] }
toml = "1.1.2"
slint = { version = "1.16.1", features = ["renderer-software"], optional = true }
toml = { version = "1.1.2", optional = true }
ttf-parser = "0.25.1"

# Only pulled in when compiling to wasm32; the native build never sees it.
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"

[[example]]
name = "gallery"
required-features = ["render"]

[[example]]
name = "icon"
required-features = ["render"]

[[example]]
name = "site"
required-features = ["render"]

# Optional so the WebAssembly build (native off) skips compiling the Slint
# toolchain and its asset-processing tree entirely.
[build-dependencies]
slint-build = "1.16.1"
slint-build = { version = "1.16.1", optional = true }

# The wasm module embeds the Montserrat faces for title metrics, so its size is
# dominated by font data — and the wasm-opt binary wasm-pack bundles is too old
# for the bulk-memory ops modern rustc emits. Skip it for a reproducible build.
[package.metadata.wasm-pack.profile.release]
wasm-opt = false

[profile.release]
opt-level = 3
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ the sumi-ê palette) with [skvggor.dev](https://skvggor.dev) and
Only the **title** is required; category, date, number and brand are optional, keeping the
cover generic enough for any platform (blog, dev.to, LinkedIn, X, OG image, thumbnail…).

## Try it in the browser

The [landing page](https://skvggor.github.io/acag/) runs the real engine,
compiled to WebAssembly: the house plates a new cover every few seconds, and you
can type your own title and watch it typeset live.

## Download

Prebuilt binaries are attached to each [GitHub release](https://github.com/skvggor/acag/releases). No Rust toolchain or system dependencies required.
Expand Down Expand Up @@ -155,6 +161,11 @@ cargo run --example gallery # regenerate docs/samples
cargo run --example icon # regenerate the app icon
```

The landing page lives in `web/` and deploys to GitHub Pages on pushes to
`main` (`deploy-site.yml`): `wasm-pack` compiles the pure cover core, the
`site` example renders the page's imagery with the engine itself, and
`build_site` assembles `dist/`.

## Credits

- **Montserrat** by Julieta Ulanovsky et al., under the [SIL Open Font License](https://github.com/JulietaUla/Montserrat).
Expand Down
2 changes: 2 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
fn main() {
// Only the desktop app compiles the Slint UI; render-only and wasm builds skip it.
#[cfg(feature = "gui")]
slint_build::compile("ui/app.slint").expect("failed to compile slint UI");

// Embed the app icon as a Windows resource so the .exe shows it in Explorer
Expand Down
129 changes: 129 additions & 0 deletions examples/build_site.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//! Assembles the landing page for GitHub Pages: minifies `index.html` and copies
//! everything else from `web/` into `dist/` verbatim. Dependency-free (std only)
//! so it stays on-stack and adds nothing to the library's test build.
//!
//! Output goes to `dist/` (not `docs/`, which holds the README gallery samples).
//! The GitHub Pages workflow publishes `dist/`.
//!
//! Run with: `cargo run --example build_site` (after `cargo run --example site`
//! and the wasm-pack build have populated `web/`).

use std::fs;
use std::path::Path;

/// Collapse whitespace and drop comments in HTML markup, leaving the content of
/// `<script>` and `<style>` blocks untouched.
fn minify_html(html: &str) -> String {
let lower = html.to_ascii_lowercase();
let mut out = String::with_capacity(html.len());
let mut index = 0;
while index < html.len() {
let next = ["<script", "<style"]
.iter()
.filter_map(|tag| lower[index..].find(tag).map(|position| index + position))
.min();
match next {
Some(start) => {
out.push_str(&collapse_markup(&html[index..start]));
let close = if lower[start..].starts_with("<script") {
"</script>"
} else {
"</style>"
};
let end = lower[start..]
.find(close)
.map(|position| start + position + close.len())
.unwrap_or(html.len());
out.push_str(&html[start..end]);
index = end;
}
None => {
out.push_str(&collapse_markup(&html[index..]));
break;
}
}
}
out
}

fn collapse_markup(segment: &str) -> String {
// Strip `<!-- … -->` comments.
let mut stripped = String::with_capacity(segment.len());
let mut rest = segment;
while let Some(start) = rest.find("<!--") {
stripped.push_str(&rest[..start]);
match rest[start..].find("-->") {
Some(end) => rest = &rest[start + end + 3..],
None => {
rest = "";
break;
}
}
}
stripped.push_str(rest);

// Collapse every whitespace run to a single space.
let mut out = String::with_capacity(stripped.len());
let mut previous_was_space = false;
for character in stripped.chars() {
if character.is_whitespace() {
if !previous_was_space {
out.push(' ');
previous_was_space = true;
}
} else {
out.push(character);
previous_was_space = false;
}
}
out
}

fn copy_tree(source: &Path, destination: &Path, minified: &mut usize, copied: &mut usize) {
for entry in fs::read_dir(source).expect("read source dir") {
let entry = entry.expect("dir entry");
let path = entry.path();
let name = entry.file_name();

// Never carry wasm-pack's `.gitignore` (which would hide dist/ from git).
if name == ".gitignore" {
continue;
}

let target = destination.join(&name);
if path.is_dir() {
fs::create_dir_all(&target).expect("create dir");
copy_tree(&path, &target, minified, copied);
} else if name == "index.html" {
let html = fs::read_to_string(&path).expect("read html");
let small = minify_html(&html);
fs::write(&target, &small).expect("write html");
println!(
" minify {} : {} -> {} bytes",
name.to_string_lossy(),
html.len(),
small.len()
);
*minified += 1;
} else {
fs::copy(&path, &target).expect("copy file");
*copied += 1;
}
}
}

fn main() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let source = root.join("web");
let output = root.join("dist");

if output.exists() {
fs::remove_dir_all(&output).expect("clean dist");
}
fs::create_dir_all(&output).expect("create dist");

let mut minified = 0;
let mut copied = 0;
copy_tree(&source, &output, &mut minified, &mut copied);
println!("built dist/: {minified} minified, {copied} copied verbatim");
}
Loading
Loading