1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
pub(crate) mod feed;
mod index;
mod links;
mod renderer;
mod sitemap;
mod writer;
use crate::util::resolve_to_absolute_path;
use crate::web::language::{parse_supported_language, system_language};
use crate::Context;
use anyhow::{bail, Context as _, Result};
use camino::Utf8PathBuf;
use clap::{Args, Subcommand};
use fluent_templates::Loader;
use unic_langid::LanguageIdentifier;
#[derive(Debug, Args)]
pub struct BuildArgs {
#[command(subcommand)]
pub command: BuildCommand,
}
#[derive(Debug, Subcommand)]
pub enum BuildCommand {
/// Generate a self-contained static website from your recipes
///
/// Renders your recipes as static HTML files browsable on any static-file
/// host or directly from disk via file://. Excludes dynamic features
/// (shopping list, pantry, editing).
///
/// Unlike `cook server`, which picks a language per request from the
/// browser's Accept-Language header, the static site is rendered in a
/// single language chosen at build time via `--lang` (default: the
/// system locale, falling back to en-US).
///
/// Examples:
/// cook build web # Build to ./_site
/// cook build web out # Build to ./out
/// cook build web --base-path ~/recipes # Use specific source directory
/// cook build web --base-url /recipes/ # Absolute URL prefix for subpath hosting
/// cook build web --lang fr-FR # Render the site in French
/// cook build web --compress # Also write .gz copies for precompressed hosting
/// cook build web --sitemap https://recipes.example.com --feed # Also write atom.xml and rss.xml
Web(WebBuildArgs),
}
#[derive(Debug, Args)]
pub struct WebBuildArgs {
/// Output directory for the generated static site
///
/// Defaults to ./_site if not specified. The directory is created if
/// missing. Existing files in the directory are overwritten as needed
/// but not wiped wholesale.
#[arg(value_hint = clap::ValueHint::DirPath)]
pub output_dir: Option<Utf8PathBuf>,
/// Root directory containing your recipe files
#[arg(long, value_hint = clap::ValueHint::DirPath)]
pub base_path: Option<Utf8PathBuf>,
/// Absolute URL prefix for hosting under a subpath (e.g. /recipes/)
///
/// When set, internal links use this absolute prefix instead of
/// page-relative paths. Useful when you know the deployed subpath.
#[arg(long)]
pub base_url: Option<String>,
/// UI language for the generated site
///
/// The static site is rendered in a single language chosen at build
/// time (unlike `cook server`, which negotiates per request from the
/// browser's Accept-Language header). Defaults to the system locale,
/// falling back to en-US.
///
/// Accepts a BCP-47 tag like `de-DE`, or a bare language code like `de`
/// that matches a supported region. Supported: en-US, de-DE, nl-NL,
/// fr-FR, es-ES, eu-ES, sv-SE, it-IT, ja-JP.
#[arg(long, value_parser = parse_lang_arg)]
pub lang: Option<LanguageIdentifier>,
/// Full base URL of the deployed site to generate a sitemap.xml
///
/// When set (e.g. https://recipes.example.com or
/// https://example.com/recipes), writes a sitemaps.org-compliant
/// sitemap.xml at the output root listing every page with absolute URLs.
/// This is the complete URL prefix (scheme + host + optional subpath) and
/// is independent of --base-url. Omit it to skip sitemap generation.
#[arg(long)]
pub sitemap: Option<String>,
/// Generate web feeds, optionally for a given site URL
///
/// Writes `atom.xml` and `rss.xml` at the output root with one item per
/// recipe and menu, newest first. An item's date is its `date` metadata
/// (YYYY-MM-DD or RFC 3339) when present, otherwise the file's
/// modification time.
///
/// Feeds need absolute links, so they need the site's full base URL.
/// `--feed` alone reuses the --sitemap URL; `--feed=URL` (with `=`) sets
/// it explicitly. Like --sitemap, this is independent of --base-url.
/// Omit it to skip feed generation.
#[arg(long, value_name = "URL", num_args = 0..=1, require_equals = true)]
pub feed: Option<Option<String>>,
/// URL of the recipe repository, linked from the site footer
///
/// When set, the generated footer's "Built with CookCLI" line gains a
/// "View source" link pointing here. Omit it to skip the link.
#[arg(long)]
pub repo_url: Option<String>,
/// Also write gzip-compressed copies (.gz) of generated text assets
///
/// Writes a `<file>.gz` next to every HTML, CSS, JS, JSON, XML, SVG and
/// .cook file so static hosts that support precompressed assets (e.g.
/// GitLab Pages) can serve them directly. Images are skipped.
#[arg(long)]
pub compress: bool,
}
fn parse_lang_arg(s: &str) -> Result<LanguageIdentifier, String> {
parse_supported_language(s).ok_or_else(|| {
format!(
"unsupported language '{s}'. Supported: en-US, de-DE, nl-NL, fr-FR, es-ES, eu-ES, sv-SE, it-IT, ja-JP"
)
})
}
impl BuildArgs {
pub fn get_base_path(&self) -> Option<Utf8PathBuf> {
match &self.command {
BuildCommand::Web(args) => args.base_path.clone(),
}
}
}
pub fn run(ctx: &Context, args: BuildArgs) -> Result<()> {
match args.command {
BuildCommand::Web(web_args) => run_web(ctx, web_args),
}
}
fn run_web(ctx: &Context, args: WebBuildArgs) -> Result<()> {
let source = resolve_to_absolute_path(ctx.base_path())?;
if !source.is_dir() {
bail!("Source base path is not a directory: {source}");
}
// `--feed https://...` (space, not `=`) leaves the URL as the positional
// output directory. Catch it before creating a directory named `https:`.
if let Some(dir) = &args.output_dir {
if dir.as_str().contains("://") {
bail!(
"Output directory looks like a URL: {dir}. To give --feed a URL, use --feed={dir}"
);
}
}
let output_raw = args
.output_dir
.clone()
.unwrap_or_else(|| Utf8PathBuf::from("_site"));
// Create the output directory before canonicalizing (canonicalize requires existence).
std::fs::create_dir_all(&output_raw)
.with_context(|| format!("Failed to create output directory: {output_raw}"))?;
let output = resolve_to_absolute_path(&output_raw)?;
let lang = args.lang.clone().unwrap_or_else(system_language);
println!("Building static site from {source} into {output} (language: {lang})");
let base_url = args.base_url.as_deref();
// Validate the sitemap URL up front so a typo fails fast, before spending
// time rendering the whole site. The actual sitemap is written at the end.
let sitemap_base = args.sitemap.as_deref();
if let Some(base) = sitemap_base {
validate_site_url("--sitemap", base)?;
}
// `--feed` alone reuses the --sitemap URL (already validated above).
let feed_base = match &args.feed {
None => None,
Some(Some(base)) => {
validate_site_url("--feed", base)?;
Some(base.as_str())
}
Some(None) => match sitemap_base {
Some(base) => Some(base),
None => bail!(
"--feed needs the site's URL: pass --sitemap <URL>, or --feed=<URL>, e.g. --feed=https://recipes.example.com"
),
},
};
// Validate the repo URL up front for the same reason as --sitemap above.
let repo_url = args.repo_url.as_deref();
if let Some(repo) = repo_url {
let parsed =
url::Url::parse(repo).with_context(|| format!("Invalid --repo-url URL: {repo}"))?;
if parsed.host().is_none() || !matches!(parsed.scheme(), "http" | "https") {
bail!("--repo-url must be an absolute http(s) URL, e.g. https://github.com/user/repo");
}
}
renderer::render_index(&source, &output, base_url, repo_url, &lang)?;
let mut tree = cooklang_find::build_tree(&source)
.map_err(|e| anyhow::anyhow!("Failed to build recipe tree: {e}"))?;
// If the user pointed the output directory inside the source directory
// (the common case: `cook build web` with default `_site` next to recipes),
// strip the output subtree so we don't re-process the previous run's
// generated files. Without this, every run would nest `_site/recipe/...`
// and `_site/api/static/...` one level deeper until the OS rejects the
// path length. The listing pages prune it the same way.
crate::web::builders::prune_subtree(&mut tree, &output);
walk_directories(
&tree,
&source,
&output,
base_url,
repo_url,
&lang,
String::new(),
)?;
let aisle = ctx.aisle().path().map(camino::Utf8Path::to_path_buf);
let recipe_count = walk_recipes(
&tree,
&source,
&output,
aisle.as_ref(),
base_url,
repo_url,
&lang,
String::new(),
)?;
let image_count = copy_all_images(&source, &output)?;
let asset_count = writer::copy_static_assets(&output)?;
let entries = index::build_search_index(&tree);
let entry_count = entries.len();
let json = serde_json::to_string(&entries)?;
// A script assigning a global, not JSON: file:// documents are opaque
// origins, so search.js cannot fetch the index from disk. See docs/build.md.
let script = format!("window.__SEARCH_INDEX__ = {json};\n");
writer::write_bytes(
&output,
camino::Utf8Path::new("static/search-index.js"),
script.as_bytes(),
)?;
// The URL was already validated near the top of run_web.
let sitemap_written = if let Some(base) = sitemap_base {
sitemap::write_sitemap(&output, base, &tree)?;
true
} else {
false
};
let feed_count = if let Some(base) = feed_base {
let title = crate::web::i18n::LOCALES.lookup(&lang, "recipes-title");
Some(feed::write_feeds(
&output,
base,
&title,
&lang.to_string(),
&tree,
)?)
} else {
None
};
let compressed_note = if args.compress {
let compressed_count = writer::compress_output(&output)?;
format!(", {compressed_count} files compressed")
} else {
String::new()
};
let sitemap_note = if sitemap_written { ", sitemap.xml" } else { "" };
let feed_note = match feed_count {
Some(n) => format!(", atom.xml and rss.xml ({n} items)"),
None => String::new(),
};
println!(
"Wrote index, directories, {recipe_count} recipe pages, {image_count} images, {asset_count} static assets, {entry_count} search entries{sitemap_note}{feed_note}{compressed_note}"
);
Ok(())
}
/// Check that `base` is an absolute http(s) site URL usable as a prefix for
/// absolute page links (sitemap, feeds). `flag` names the option in errors.
fn validate_site_url(flag: &str, base: &str) -> Result<()> {
let parsed = url::Url::parse(base).with_context(|| format!("Invalid {flag} URL: {base}"))?;
if parsed.host().is_none()
|| !matches!(parsed.scheme(), "http" | "https")
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
bail!("{flag} must be an absolute http(s) URL with a host and no query or fragment, e.g. https://recipes.example.com");
}
Ok(())
}
fn copy_all_images(source: &camino::Utf8Path, output: &camino::Utf8Path) -> Result<usize> {
let mut count = 0;
// `walkdir` doesn't follow symlinks by default, which prevents infinite
// loops on symlink cycles. We also filter out dotted directories so that
// hidden caches (.git, .cache, etc.) are skipped. Skip the output
// subtree too — when the user builds into a directory inside the source
// (default: `_site` next to recipes), we'd otherwise re-copy the
// previous run's images into a deeper path each time.
let output_std = output.as_std_path();
let walker = walkdir::WalkDir::new(source).into_iter().filter_entry(|e| {
if e.path().starts_with(output_std) {
return false;
}
!e.file_type().is_dir()
|| e.file_name()
.to_str()
.map(|n| !n.starts_with('.'))
.unwrap_or(true)
});
for entry in walker {
let entry = entry.with_context(|| format!("Failed to walk {source}"))?;
if !entry.file_type().is_file() {
continue;
}
let path = camino::Utf8PathBuf::try_from(entry.into_path())
.map_err(|e| anyhow::anyhow!("Non-UTF-8 path: {e}"))?;
if let Some("jpg" | "jpeg" | "png" | "gif" | "webp" | "avif") =
path.extension().map(|e| e.to_ascii_lowercase()).as_deref()
{
writer::copy_image(output, source, &path)?;
count += 1;
}
}
Ok(count)
}
fn walk_directories(
tree: &cooklang_find::RecipeTree,
source: &camino::Utf8Path,
output: &camino::Utf8Path,
base_url: Option<&str>,
repo_url: Option<&str>,
lang: &unic_langid::LanguageIdentifier,
prefix_path: String,
) -> Result<()> {
for (name, child) in &tree.children {
if child.children.is_empty() {
continue; // it's a recipe file, handled by walk_recipes
}
let sub = if prefix_path.is_empty() {
name.to_string()
} else {
format!("{prefix_path}/{name}")
};
renderer::render_directory(source, output, &sub, base_url, repo_url, lang)?;
walk_directories(child, source, output, base_url, repo_url, lang, sub)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn walk_recipes(
tree: &cooklang_find::RecipeTree,
source: &camino::Utf8Path,
output: &camino::Utf8Path,
aisle_path: Option<&camino::Utf8PathBuf>,
base_url: Option<&str>,
repo_url: Option<&str>,
lang: &unic_langid::LanguageIdentifier,
prefix_path: String,
) -> Result<usize> {
let mut count = 0;
for (name, child) in &tree.children {
if child.children.is_empty() {
// Recipe file — use the on-disk file name, not the tree key (which may be the title).
let leaf_name = child
.recipe
.as_ref()
.and_then(|r| r.file_name())
.unwrap_or_else(|| name.to_string());
let sub = if prefix_path.is_empty() {
leaf_name
} else {
format!("{prefix_path}/{leaf_name}")
};
if let Err(e) =
renderer::render_recipe(source, output, &sub, aisle_path, base_url, repo_url, lang)
{
tracing::warn!("Skipping recipe {sub}: {e:#}");
continue;
}
if sub.ends_with(".cook") {
if let Err(e) = writer::copy_recipe_source(output, source, &sub) {
tracing::warn!("Skipping source copy for {sub}: {e:#}");
}
}
count += 1;
} else {
let sub = if prefix_path.is_empty() {
name.to_string()
} else {
format!("{prefix_path}/{name}")
};
count += walk_recipes(
child, source, output, aisle_path, base_url, repo_url, lang, sub,
)?;
}
}
Ok(count)
}