Update bDS2 rendering fixtures and routes.

This commit is contained in:
2026-07-20 18:07:54 +02:00
parent 49adfed68f
commit 47d069dfac
35 changed files with 18407 additions and 21747 deletions

View File

@@ -20,7 +20,7 @@ The project is under active development. Core blogging workflows are broadly ava
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration.
- A fully localized Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client locale updates, and airplane-mode AI gating.
- `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
- Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups.
- bDS2-compatible Markdown/Liquid rendering with native macros, canonical multilingual and flat page routes, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, and incremental site generation through cancellable section task groups.
- Navigable generated-route preview in the app or system browser, with draft database overlays and published filesystem content.
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and status-bar airplane-mode routing.
- Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript.

View File

@@ -15,7 +15,7 @@
</svg>
</button>
<div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}"></div>
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}" data-search-no-results="{{ 'render.search.noResults' | i18n: language }}"></div>
</div>
</div>
</nav>
@@ -35,7 +35,7 @@
</svg>
</button>
<div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}"></div>
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}" data-search-no-results="{{ 'render.search.noResults' | i18n: language }}"></div>
</div>
</div>
{% endif %}

View File

@@ -13,10 +13,10 @@ use crate::engine::validate_site::SiteValidationReport;
use crate::engine::{EngineError, EngineResult};
use crate::model::{CategorySettings, Post, ProjectMetadata};
use crate::render::{
GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path,
GeneratedWriteOutcome, PostLanguageVariant, build_calendar_json, build_canonical_post_path,
build_site_render_artifacts, build_site_section_render_artifacts,
build_targeted_site_section_render_artifacts, render_markdown_to_html, write_generated_bytes,
write_generated_file,
build_targeted_site_section_render_artifacts, select_post_language_variant,
write_generated_bytes, write_generated_file,
};
#[derive(Debug, Clone)]
@@ -163,8 +163,6 @@ pub fn render_site_section_with_progress(
return Err(EngineError::Validation("cancelled".to_string()));
}
let data_dir = project_data_dir(output_dir);
let category_settings = load_category_settings(&data_dir);
let list_posts = filter_posts_for_lists(posts, &category_settings);
let input_posts = posts
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
@@ -202,7 +200,7 @@ pub fn render_site_section_with_progress(
project_id,
metadata,
&data_dir,
&list_posts,
posts,
&artifacts.route_manifest,
None,
&mut report,
@@ -305,8 +303,6 @@ pub fn apply_validation_section_with_progress(
return Err(EngineError::Validation("cancelled".to_string()));
}
let data_dir = project_data_dir(output_dir);
let category_settings = load_category_settings(&data_dir);
let list_posts = filter_posts_for_lists(posts, &category_settings);
let input_posts = posts
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
@@ -368,7 +364,7 @@ pub fn apply_validation_section_with_progress(
project_id,
metadata,
&data_dir,
&list_posts,
posts,
&artifacts.route_manifest,
(!fallback).then_some(&requested),
&mut report,
@@ -400,7 +396,7 @@ fn write_core_outputs(
project_id: &str,
metadata: &ProjectMetadata,
data_dir: &Path,
list_posts: &[PublishedPostSource],
published_posts: &[PublishedPostSource],
route_manifest: &[crate::render::SitePage],
requested: Option<&HashSet<String>>,
report: &mut GenerationReport,
@@ -412,7 +408,7 @@ fn write_core_outputs(
let mut outputs = vec![(
"calendar.json".to_string(),
build_calendar_json(
&list_posts
&published_posts
.iter()
.map(|source| source.post.clone())
.collect::<Vec<_>>(),
@@ -420,23 +416,53 @@ fn write_core_outputs(
)];
for render_language in render_languages(metadata) {
let localized_posts =
localized_sources(conn, data_dir, list_posts, &render_language, metadata)?;
let prefix = if render_language == metadata.main_language.as_deref().unwrap_or("en") {
localized_sources(conn, data_dir, published_posts, &render_language, metadata)?;
let is_main = render_language == metadata.main_language.as_deref().unwrap_or("en");
let prefix = if is_main {
String::new()
} else {
format!("{render_language}/")
};
let rss = build_rss_xml(metadata, &localized_posts, &render_language);
outputs.push((format!("{prefix}rss.xml"), rss.clone()));
outputs.push((format!("{prefix}feed.xml"), rss));
let mut feed_posts = if is_main {
published_posts.to_vec()
} else {
localized_posts
.iter()
.filter(|source| {
source
.post
.language
.as_deref()
.is_some_and(|language| language.eq_ignore_ascii_case(&render_language))
})
.cloned()
.collect::<Vec<_>>()
};
sort_published_sources(&mut feed_posts);
outputs.push((
format!("{prefix}rss.xml"),
build_rss_xml(metadata, &feed_posts, &render_language),
));
outputs.push((
format!("{prefix}atom.xml"),
build_atom_xml(metadata, &localized_posts, &render_language),
));
outputs.push((
format!("{prefix}sitemap.xml"),
build_sitemap_xml(metadata, route_manifest, &localized_posts, &render_language),
build_atom_xml(metadata, &feed_posts, &render_language),
));
if is_main {
let category_settings = load_category_settings(data_dir);
let mut sitemap_posts = published_posts.to_vec();
sort_published_sources(&mut sitemap_posts);
let sitemap_list_posts = filter_posts_for_lists(&sitemap_posts, &category_settings);
outputs.push((
"sitemap.xml".to_string(),
build_sitemap_xml(
metadata,
route_manifest,
&sitemap_posts,
&sitemap_list_posts,
&render_language,
),
));
}
}
for (path, content) in outputs {
if is_cancelled() {
@@ -673,10 +699,9 @@ fn expected_paths_for_sections(
format!("{language}/")
};
expected.insert(format!("{prefix}rss.xml"));
expected.insert(format!("{prefix}feed.xml"));
expected.insert(format!("{prefix}atom.xml"));
expected.insert(format!("{prefix}sitemap.xml"));
}
expected.insert("sitemap.xml".to_string());
}
expected
@@ -775,6 +800,7 @@ pub(crate) fn classify_generated_path(path: &str) -> Option<GenerationSection> {
{
Some(GenerationSection::Single)
}
[_slug, "index.html"] => Some(GenerationSection::Core),
_ => None,
}
}
@@ -782,9 +808,7 @@ pub(crate) fn classify_generated_path(path: &str) -> Option<GenerationSection> {
fn has_language_prefix(parts: &[&str]) -> bool {
match parts {
[first, second, ..] => {
!is_year_segment(first)
&& *first != "category"
&& *first != "tag"
matches!(*first, "de" | "en" | "es" | "fr" | "it")
&& (*second == "index.html"
|| *second == "404.html"
|| *second == "page"
@@ -859,38 +883,68 @@ fn localized_sources(
let main_language = metadata.main_language.as_deref().unwrap_or("en");
let mut localized = Vec::new();
for source in posts {
if language.eq_ignore_ascii_case(main_language) {
localized.push(source.clone());
continue;
}
if let Ok(translation) =
queries::post_translation::get_post_translation_by_post_and_language(
conn,
&source.post.id,
language,
)
{
let raw = std::fs::read_to_string(
data_dir.join(translation.file_path.trim_start_matches('/')),
)
.map_err(EngineError::Io)?;
let (_, body) = crate::util::frontmatter::read_translation_file(&raw)
.map_err(EngineError::Parse)?;
let mut translated_post = source.post.clone();
translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.file_path = translation.file_path.clone();
translated_post.published_at = translation.published_at.or(source.post.published_at);
localized.push(PublishedPostSource {
post: translated_post,
body_markdown: body,
});
let translation = queries::post_translation::get_post_translation_by_post_and_language(
conn,
&source.post.id,
language,
)
.ok()
.filter(|translation| {
!translation.file_path.trim().is_empty()
&& data_dir
.join(translation.file_path.trim_start_matches('/'))
.is_file()
});
match select_post_language_variant(
&source.post,
language,
main_language,
translation.is_some(),
) {
Some(PostLanguageVariant::Base) => localized.push(source.clone()),
Some(PostLanguageVariant::Translation) => {
let Some(translation) = translation else {
continue;
};
let raw = std::fs::read_to_string(
data_dir.join(translation.file_path.trim_start_matches('/')),
)
.map_err(EngineError::Io)?;
let (_, body) = crate::util::frontmatter::read_translation_file(&raw)
.map_err(EngineError::Parse)?;
let mut translated_post = source.post.clone();
translated_post.id = translation.id.clone();
translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.status = translation.status.clone();
translated_post.file_path = translation.file_path.clone();
translated_post.updated_at = translation.updated_at;
translated_post.published_at =
translation.published_at.or(source.post.published_at);
localized.push(PublishedPostSource {
post: translated_post,
body_markdown: body,
});
}
None => {}
}
}
sort_published_sources(&mut localized);
Ok(localized)
}
fn sort_published_sources(posts: &mut [PublishedPostSource]) {
posts.sort_by(|left, right| {
right
.post
.created_at
.cmp(&left.post.created_at)
.then_with(|| right.post.published_at.cmp(&left.post.published_at))
.then_with(|| left.post.slug.cmp(&right.post.slug))
});
}
fn load_category_settings(data_dir: &Path) -> HashMap<String, CategorySettings> {
crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default()
}
@@ -905,8 +959,7 @@ fn filter_posts_for_lists(
!source.post.categories.iter().any(|category| {
category_settings
.get(category)
.map(|settings| !settings.render_in_lists)
.unwrap_or(false)
.is_some_and(|settings| !settings.render_in_lists)
})
})
.cloned()
@@ -923,74 +976,21 @@ pub(crate) fn build_rss_xml(
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let last_build = posts
.iter()
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
.max()
.unwrap_or_else(Utc::now);
let mut xml = vec![
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
"<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">".to_string(),
" <channel>".to_string(),
format!(" <title>{}</title>", escape_xml(&metadata.name)),
format!(" <link>{base_url}/</link>"),
format!(" <description>{}</description>", escape_xml(metadata.description.as_deref().unwrap_or(""))),
format!(" <lastBuildDate>{}</lastBuildDate>", last_build.format("%a, %d %b %Y %H:%M:%S GMT")),
" <generator>bDS</generator>".to_string(),
];
let mut xml = format!(
"<rss><channel><title>{} ({})</title>",
escape_xml(&metadata.name),
escape_xml(language)
);
for source in posts {
let url = format!(
"{base_url}{}",
build_canonical_post_path(
&source.post,
language,
metadata.main_language.as_deref().unwrap_or("en")
)
);
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
.unwrap_or_else(Utc::now);
xml.push(" <item>".to_string());
xml.push(format!(
" <title>{}</title>",
let url = post_absolute_url(base_url, metadata, source, language);
xml.push_str(&format!(
"<item><title>{}</title><link>{url}</link></item>",
escape_xml(&source.post.title)
));
xml.push(format!(" <link>{url}</link>"));
xml.push(format!(" <guid isPermaLink=\"true\">{url}</guid>"));
xml.push(format!(
" <pubDate>{}</pubDate>",
published.format("%a, %d %b %Y %H:%M:%S GMT")
));
if let Some(author) = &source.post.author {
xml.push(format!(" <author>{}</author>", escape_xml(author)));
}
xml.push(format!(
" <dc:language>{}</dc:language>",
escape_xml(language)
));
let html = render_markdown_to_html(&source.body_markdown);
xml.push(format!(
" <description><![CDATA[{html}]]></description>"
));
xml.push(format!(
" <content:encoded><![CDATA[{html}]]></content:encoded>"
));
for category in &source.post.categories {
xml.push(format!(
" <category>{}</category>",
escape_xml(category)
));
}
for tag in &source.post.tags {
xml.push(format!(" <category>{}</category>", escape_xml(tag)));
}
xml.push(" </item>".to_string());
}
xml.push(" </channel>".to_string());
xml.push("</rss>".to_string());
xml.join("\n")
xml.push_str("</channel></rss>");
xml
}
pub(crate) fn build_atom_xml(
@@ -1003,85 +1003,45 @@ pub(crate) fn build_atom_xml(
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let main_language = metadata.main_language.as_deref().unwrap_or("en");
let feed_prefix = language_prefix(language, main_language);
let updated = posts
.iter()
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
.max()
.unwrap_or_else(Utc::now);
let mut xml = vec![
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
"<feed xmlns=\"http://www.w3.org/2005/Atom\">".to_string(),
format!(" <title>{}</title>", escape_xml(&metadata.name)),
format!(
" <subtitle>{}</subtitle>",
escape_xml(metadata.description.as_deref().unwrap_or(""))
),
format!(" <id>{base_url}/</id>"),
format!(" <link href=\"{base_url}{feed_prefix}/\" rel=\"alternate\" />"),
format!(" <link href=\"{base_url}{feed_prefix}/atom.xml\" rel=\"self\" />"),
format!(
" <updated>{}</updated>",
updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
),
];
let mut xml = format!(
"<feed><title>{} ({})</title>",
escape_xml(&metadata.name),
escape_xml(language)
);
for source in posts {
let url = format!(
"{base_url}{}",
build_canonical_post_path(
&source.post,
language,
metadata.main_language.as_deref().unwrap_or("en")
)
);
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
.unwrap_or_else(Utc::now);
let html = render_markdown_to_html(&source.body_markdown);
xml.push(format!(" <entry xml:lang=\"{}\">", escape_xml(language)));
xml.push(format!(
" <title>{}</title>",
let url = post_absolute_url(base_url, metadata, source, language);
xml.push_str(&format!(
"<entry><title>{}</title><id>{url}</id></entry>",
escape_xml(&source.post.title)
));
xml.push(format!(" <id>{url}</id>"));
xml.push(format!(" <link href=\"{url}\" />"));
xml.push(format!(
" <updated>{}</updated>",
published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
));
xml.push(format!(
" <published>{}</published>",
published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
));
if let Some(author) = &source.post.author {
xml.push(format!(
" <author><name>{}</name></author>",
escape_xml(author)
));
}
xml.push(format!(" <summary type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></summary>"));
xml.push(format!(" <content type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></content>"));
for category in &source.post.categories {
xml.push(format!(
" <category term=\"{}\" />",
escape_xml(category)
));
}
for tag in &source.post.tags {
xml.push(format!(" <category term=\"{}\" />", escape_xml(tag)));
}
xml.push(" </entry>".to_string());
}
xml.push_str("</feed>");
xml
}
xml.push("</feed>".to_string());
xml.join("\n")
fn post_absolute_url(
base_url: &str,
metadata: &ProjectMetadata,
source: &PublishedPostSource,
language: &str,
) -> String {
format!(
"{base_url}{}/",
build_canonical_post_path(
&source.post,
language,
metadata.main_language.as_deref().unwrap_or("en")
)
.trim_end_matches('/')
)
}
fn build_sitemap_xml(
metadata: &ProjectMetadata,
pages: &[crate::render::SitePage],
posts: &[PublishedPostSource],
list_posts: &[PublishedPostSource],
language: &str,
) -> String {
let base_url = metadata
@@ -1091,23 +1051,31 @@ fn build_sitemap_xml(
.trim_end_matches('/');
let main_language = metadata.main_language.as_deref().unwrap_or("en");
let languages = render_languages(metadata);
let index_lastmod = posts
.iter()
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
.max()
let index_lastmod = list_posts
.first()
.and_then(|post| timestamp(post.post.updated_at))
.unwrap_or_else(Utc::now)
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let post_lastmod_by_path = posts
.iter()
.filter_map(|source| {
timestamp(source.post.published_at.unwrap_or(source.post.created_at)).map(|lastmod| {
(
build_canonical_post_path(&source.post, language, main_language),
lastmod.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
)
})
})
.collect::<HashMap<_, _>>();
let mut post_lastmod_by_path = HashMap::new();
for source in posts {
let Some(lastmod) = timestamp(source.post.updated_at) else {
continue;
};
let lastmod = lastmod.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
post_lastmod_by_path.insert(
build_canonical_post_path(&source.post, language, main_language),
lastmod.clone(),
);
if source
.post
.categories
.iter()
.any(|category| category == "page")
{
let prefix = language_prefix(language, main_language);
post_lastmod_by_path.insert(format!("{prefix}/{}", source.post.slug), lastmod.clone());
}
}
let page_groups = group_pages_by_logical_path(pages, &languages, main_language);
let mut xml = vec![
@@ -1115,41 +1083,82 @@ fn build_sitemap_xml(
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">".to_string(),
];
for page in pages.iter().filter(|page| page.language == language) {
let url = format!("{base_url}{}", page.url_path);
let mut language_pages = pages
.iter()
.filter(|page| page.language == language)
.collect::<Vec<_>>();
language_pages.sort_by(|left, right| {
let left_key = logical_page_key(&left.relative_path, &languages, main_language);
let right_key = logical_page_key(&right.relative_path, &languages, main_language);
let left_rank = sitemap_rank(&left_key);
let right_rank = sitemap_rank(&right_key);
left_rank.cmp(&right_rank).then_with(|| {
if (4..=6).contains(&left_rank) {
right_key.cmp(&left_key)
} else {
std::cmp::Ordering::Equal
}
})
});
for page in language_pages {
let logical_key = logical_page_key(&page.relative_path, &languages, main_language);
if logical_key.contains("/page/") && !logical_key.starts_with("page/") {
continue;
}
let url_path = sitemap_url_path(&page.url_path);
let url = format!("{base_url}{url_path}");
let alternates = page_groups.get(&logical_key);
let lastmod = post_lastmod_by_path
.get(&page.url_path)
.cloned()
.unwrap_or_else(|| index_lastmod.clone());
let is_home = page.url_path == language_root_url_path(language, main_language);
let (changefreq, priority) = sitemap_metadata(&logical_key, is_home);
let rank = sitemap_rank(&logical_key);
xml.push(" <url>".to_string());
xml.push(format!(" <loc>{url}</loc>"));
xml.push(format!(" <loc>{}</loc>", escape_xml(&url)));
xml.push(format!(" <lastmod>{lastmod}</lastmod>"));
xml.push(format!(
" <changefreq>{}</changefreq>",
if is_home { "daily" } else { "weekly" }
));
xml.push(format!(
" <priority>{}</priority>",
if is_home { "1.0" } else { "0.8" }
));
if let Some(alternates) = alternates {
for alternate in alternates {
xml.push(format!(" <changefreq>{}</changefreq>", changefreq));
xml.push(format!(" <priority>{}</priority>", priority));
if !matches!(rank, 2 | 3) {
for alternate_language in &languages {
let alternate_path = if alternate_language == main_language {
page.url_path.clone()
} else if page.url_path == "/" {
format!("/{alternate_language}")
} else {
format!("/{alternate_language}{}", page.url_path)
};
let href = format!("{base_url}{}", sitemap_url_path(&alternate_path));
xml.push(format!(
" <xhtml:link rel=\"alternate\" hreflang=\"{}\" href=\"{base_url}{}\" />",
" <xhtml:link rel=\"alternate\" hreflang=\"{}\" href=\"{}\" />",
escape_xml(alternate_language),
escape_xml(&href),
));
}
let href = format!("{base_url}{}", sitemap_url_path(&page.url_path));
xml.push(format!(
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{}\" />",
escape_xml(&href),
));
} else if let Some(alternates) = alternates {
for alternate in alternates {
let href = format!("{base_url}{}", sitemap_url_path(&alternate.url_path));
xml.push(format!(
" <xhtml:link rel=\"alternate\" hreflang=\"{}\" href=\"{}\" />",
escape_xml(&alternate.language),
alternate.url_path,
escape_xml(&href),
));
}
if let Some(default_page) = alternates
.iter()
.find(|alternate| alternate.language == main_language)
{
let href = format!("{base_url}{}", sitemap_url_path(&default_page.url_path));
xml.push(format!(
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{base_url}{}\" />",
default_page.url_path,
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{}\" />",
escape_xml(&href),
));
}
}
@@ -1157,7 +1166,65 @@ fn build_sitemap_xml(
}
xml.push("</urlset>".to_string());
xml.join("\n")
format!("{}\n", xml.join("\n"))
}
fn sitemap_url_path(path: &str) -> String {
if path == "/" {
path.to_string()
} else {
format!("{}/", path.trim_end_matches('/'))
}
}
fn sitemap_metadata(logical_path: &str, is_home: bool) -> (&'static str, &'static str) {
if is_home {
return ("daily", "1.0");
}
if logical_path.starts_with("page/") {
return ("daily", "0.9");
}
let parts = logical_path.split('/').collect::<Vec<_>>();
match parts.as_slice() {
[year, month, day, _slug, "index.html"]
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
{
("monthly", "0.8")
}
["category" | "tag", ..] => ("weekly", "0.6"),
[year, month, day, "index.html"]
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
{
("monthly", "0.4")
}
[year, ..] if is_year_segment(year) => ("monthly", "0.5"),
[_slug, "index.html"] => ("weekly", "0.7"),
_ => ("weekly", "0.6"),
}
}
fn sitemap_rank(logical_path: &str) -> u8 {
let parts = logical_path.split('/').collect::<Vec<_>>();
match parts.as_slice() {
["index.html"] => 0,
["page", _, "index.html"] => 1,
[year, month, day, _slug, "index.html"]
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
{
2
}
[year, "index.html"] if is_year_segment(year) => 4,
[year, month, "index.html"] if is_year_segment(year) && is_month_segment(month) => 5,
[year, month, day, "index.html"]
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
{
6
}
[_slug, "index.html"] => 3,
["category", ..] => 7,
["tag", ..] => 8,
_ => 9,
}
}
fn language_prefix(language: &str, main_language: &str) -> String {

View File

@@ -14,7 +14,7 @@ use crate::db::{Database, queries};
use crate::engine::generation::PublishedPostSource;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Post, PostStatus, pico_stylesheet_href};
use crate::render::build_preview_response;
use crate::render::{PostLanguageVariant, build_preview_response, select_post_language_variant};
use crate::util::frontmatter::{read_post_file, read_translation_file};
pub const PREVIEW_HOST: &str = "127.0.0.1";
@@ -204,9 +204,8 @@ fn render_preview_response(
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
let db = Database::open(&state.db_path)?;
let preview_posts = collect_preview_posts(state)?;
let list_posts = filter_preview_list_posts(&state.data_dir, &preview_posts);
if path == "/calendar.json" {
let posts = list_posts
let posts = preview_posts
.iter()
.map(|source| source.post.clone())
.collect::<Vec<_>>();
@@ -220,13 +219,27 @@ fn render_preview_response(
.into_response());
}
if let Some((language, kind)) = preview_feed_request(path, &metadata) {
let localized_posts = localized_preview_posts(
db.conn(),
&state.data_dir,
&list_posts,
&language,
metadata.main_language.as_deref().unwrap_or("en"),
)?;
let main_language = metadata.main_language.as_deref().unwrap_or("en");
let localized_posts = if language.eq_ignore_ascii_case(main_language) {
preview_posts.clone()
} else {
localized_preview_posts(
db.conn(),
&state.data_dir,
&preview_posts,
&language,
main_language,
)?
.into_iter()
.filter(|source| {
source
.post
.language
.as_deref()
.is_some_and(|post_language| post_language.eq_ignore_ascii_case(&language))
})
.collect()
};
let (content_type, xml) = match kind {
PreviewFeedKind::Rss => (
"application/rss+xml; charset=utf-8",
@@ -414,28 +427,17 @@ fn collect_preview_posts(state: &PreviewServerState) -> EngineResult<Vec<Publish
post,
});
}
preview_posts.sort_by_key(|source| source.post.published_at.unwrap_or(source.post.created_at));
preview_posts.sort_by(|left, right| {
right
.post
.created_at
.cmp(&left.post.created_at)
.then_with(|| right.post.published_at.cmp(&left.post.published_at))
.then_with(|| left.post.slug.cmp(&right.post.slug))
});
Ok(preview_posts)
}
fn filter_preview_list_posts(
data_dir: &Path,
posts: &[PublishedPostSource],
) -> Vec<PublishedPostSource> {
let settings = crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default();
posts
.iter()
.filter(|source| {
!source.post.categories.iter().any(|category| {
settings
.get(category)
.is_some_and(|setting| !setting.render_in_lists)
})
})
.cloned()
.collect()
}
fn preview_feed_request(
path: &str,
metadata: &crate::model::ProjectMetadata,
@@ -460,7 +462,7 @@ fn preview_feed_request(
_ => return None,
};
let kind = match filename {
"rss.xml" | "feed.xml" => PreviewFeedKind::Rss,
"rss.xml" => PreviewFeedKind::Rss,
"atom.xml" => PreviewFeedKind::Atom,
_ => return None,
};
@@ -474,30 +476,49 @@ fn localized_preview_posts(
language: &str,
main_language: &str,
) -> EngineResult<Vec<PublishedPostSource>> {
if language.eq_ignore_ascii_case(main_language) {
return Ok(posts.to_vec());
}
let mut localized = Vec::new();
for source in posts {
let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(
let translation = queries::post_translation::get_post_translation_by_post_and_language(
conn,
&source.post.id,
language,
) else {
continue;
};
let mut translated_post = source.post.clone();
translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.status = translation.status.clone();
translated_post.file_path = translation.file_path.clone();
translated_post.published_at = translation.published_at.or(source.post.published_at);
localized.push(PublishedPostSource {
post: translated_post,
body_markdown: load_translation_body(data_dir, &translation)?,
)
.ok()
.filter(|translation| {
(translation.status == PostStatus::Draft && translation.content.is_some())
|| (!translation.file_path.trim().is_empty()
&& data_dir
.join(translation.file_path.trim_start_matches('/'))
.is_file())
});
match select_post_language_variant(
&source.post,
language,
main_language,
translation.is_some(),
) {
Some(PostLanguageVariant::Base) => localized.push(source.clone()),
Some(PostLanguageVariant::Translation) => {
let Some(translation) = translation else {
continue;
};
let mut translated_post = source.post.clone();
translated_post.id = translation.id.clone();
translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.status = translation.status.clone();
translated_post.file_path = translation.file_path.clone();
translated_post.updated_at = translation.updated_at;
translated_post.published_at =
translation.published_at.or(source.post.published_at);
localized.push(PublishedPostSource {
post: translated_post,
body_markdown: load_translation_body(data_dir, &translation)?,
});
}
None => {}
}
}
Ok(localized)
}
@@ -789,6 +810,25 @@ mod tests {
assert!(html.contains("<strong>world</strong>"));
}
#[test]
fn preview_renders_page_category_post_at_flat_path() {
let db = Database::open_in_memory().unwrap();
let mut source = make_post();
source.post.categories = vec!["page".into()];
let response = build_preview_response(
db.conn(),
Path::new("."),
"project-1",
&make_metadata(),
&[(source.post, source.body_markdown)],
"/hello",
)
.unwrap();
assert_eq!(response.status_code, 200);
assert!(response.html.contains("<h1>Hello</h1>"));
}
#[test]
fn preview_renders_language_prefixed_single_post() {
let dir = tempfile::tempdir().unwrap();
@@ -923,11 +963,10 @@ mod tests {
assert!(translated_draft_html.contains("Deutscher <strong>Entwurf</strong>"));
assert!(calendar_json.contains("2024"));
assert!(rss_xml.contains("<rss"));
assert!(rss_xml.contains("Draft <strong>body</strong>"));
assert!(rss_xml.contains("Filesystem <strong>body</strong>"));
assert!(!rss_xml.contains("Stale database body"));
assert!(rss_xml.contains("<title>Hello</title>"));
assert!(rss_xml.contains("<title>Published from file</title>"));
assert!(translated_atom_xml.contains("<feed"));
assert!(translated_atom_xml.contains("Deutscher <strong>Entwurf</strong>"));
assert!(translated_atom_xml.contains("<title>Hallo</title>"));
}
#[test]

View File

@@ -47,10 +47,9 @@ pub fn validate_site(
format!("{language}/")
};
expected.insert(format!("{prefix}rss.xml"));
expected.insert(format!("{prefix}feed.xml"));
expected.insert(format!("{prefix}atom.xml"));
expected.insert(format!("{prefix}sitemap.xml"));
}
expected.insert("sitemap.xml".to_string());
let mut actual = HashSet::new();
if output_dir.exists() {

View File

@@ -3,7 +3,7 @@ use std::fs;
use std::path::Path;
use crate::db::DbConnection as Connection;
use chrono::{Datelike, TimeZone, Utc};
use chrono::{Datelike, Local, TimeZone};
use serde::Serialize;
use crate::db::queries::generated_file_hash as qhash;
@@ -99,7 +99,6 @@ pub fn build_core_generation_paths(main_language: &str, blog_languages: &[String
"404.html".to_string(),
"sitemap.xml".to_string(),
"rss.xml".to_string(),
"feed.xml".to_string(),
"atom.xml".to_string(),
"calendar.json".to_string(),
];
@@ -108,9 +107,7 @@ pub fn build_core_generation_paths(main_language: &str, blog_languages: &[String
if language != main_language {
paths.push(format!("{language}/index.html"));
paths.push(format!("{language}/404.html"));
paths.push(format!("{language}/sitemap.xml"));
paths.push(format!("{language}/rss.xml"));
paths.push(format!("{language}/feed.xml"));
paths.push(format!("{language}/atom.xml"));
}
}
@@ -124,8 +121,7 @@ pub fn build_calendar_archive_data(posts: &[Post]) -> CalendarArchiveData {
let mut days = BTreeMap::new();
for post in posts {
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
let Some(created_at) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
let Some(created_at) = Local.timestamp_millis_opt(post.created_at).single() else {
continue;
};
@@ -146,5 +142,5 @@ pub fn build_calendar_archive_data(posts: &[Post]) -> CalendarArchiveData {
}
pub fn build_calendar_json(posts: &[Post]) -> serde_json::Result<String> {
serde_json::to_string_pretty(&build_calendar_archive_data(posts))
serde_json::to_string(&build_calendar_archive_data(posts))
}

View File

@@ -13,6 +13,7 @@ pub use generation::{
pub use markdown::render_markdown_to_html;
pub(crate) use page_renderer::render_liquid_template_with_host;
pub use page_renderer::{RenderError, render_liquid_template};
pub(crate) use routes::{PostLanguageVariant, select_post_language_variant};
pub use routes::{
RenderedPage, build_canonical_post_path, render_starter_list_page,
render_starter_list_page_with_media_map, render_starter_single_post_page,

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap;
use chrono::{Datelike, TimeZone, Utc};
use chrono::{Datelike, Local, TimeZone};
use serde::Serialize;
use crate::i18n::normalize_language;
@@ -17,6 +17,40 @@ const STARTER_MENU_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/menu.liquid");
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
const STARTER_MENU_ITEMS_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/menu-items.liquid");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PostLanguageVariant {
Base,
Translation,
}
pub(crate) fn select_post_language_variant(
post: &Post,
target_language: &str,
main_language: &str,
has_translation: bool,
) -> Option<PostLanguageVariant> {
let source_language = post.language.as_deref().unwrap_or(main_language);
if target_language.eq_ignore_ascii_case(main_language) {
return Some(if has_translation {
PostLanguageVariant::Translation
} else {
PostLanguageVariant::Base
});
}
if post.do_not_translate {
return None;
}
if source_language.eq_ignore_ascii_case(target_language) {
Some(PostLanguageVariant::Base)
} else if has_translation {
Some(PostLanguageVariant::Translation)
} else {
Some(PostLanguageVariant::Base)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedPage {
@@ -99,8 +133,7 @@ struct PostTemplateContext<'a> {
}
pub fn build_canonical_post_path(post: &Post, language: &str, main_language: &str) -> String {
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
let Some(timestamp) = Local.timestamp_millis_opt(post.created_at).single() else {
return fallback_language_path(post, language, main_language);
};
@@ -276,8 +309,7 @@ fn starter_partials() -> HashMap<String, String> {
),
(
"partials/menu-items".to_string(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
.to_string(),
STARTER_MENU_ITEMS_PARTIAL.to_string(),
),
])
}
@@ -310,8 +342,8 @@ fn fallback_language_path(post: &Post, language: &str, main_language: &str) -> S
}
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
Utc.timestamp_millis_opt(timestamp_ms)
Local
.timestamp_millis_opt(post.created_at)
.single()
.map(|timestamp| (timestamp.year(), timestamp.month()))
.unwrap_or((1970, 1))
@@ -406,8 +438,7 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
let mut current_key: Option<String> = None;
for (post, body) in posts {
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
let Some(timestamp) = Local.timestamp_millis_opt(post.created_at).single() else {
continue;
};
@@ -425,10 +456,10 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
blocks.push(DayBlockContext {
show_date_marker: true,
date_label: format!(
"{:02}.{:02}.{:04}",
timestamp.day(),
"{:04}-{:02}-{:02}",
timestamp.year(),
timestamp.month(),
timestamp.year()
timestamp.day()
),
posts: Vec::new(),
show_separator: false,
@@ -500,7 +531,8 @@ mod tests {
#[test]
fn canonical_post_paths_follow_language_prefix_rule() {
let post = make_post();
let mut post = make_post();
post.published_at = Some(1_712_678_400_000);
assert_eq!(
build_canonical_post_path(&post, "en", "en"),
"/2024/03/09/hello"
@@ -511,6 +543,52 @@ mod tests {
);
}
#[test]
fn canonical_post_paths_use_the_bds2_local_created_date() {
let mut post = make_post();
post.created_at = 1_711_927_800_000;
let local = Local
.timestamp_millis_opt(post.created_at)
.single()
.unwrap();
assert_eq!(
build_canonical_post_path(&post, "en", "en"),
format!(
"/{:04}/{:02}/{:02}/hello",
local.year(),
local.month(),
local.day()
)
);
}
#[test]
fn language_variants_match_bds2_canonical_and_fallback_rules() {
let mut post = make_post();
post.language = Some("en".into());
assert_eq!(
select_post_language_variant(&post, "de", "de", true),
Some(PostLanguageVariant::Translation)
);
assert_eq!(
select_post_language_variant(&post, "en", "de", false),
Some(PostLanguageVariant::Base)
);
assert_eq!(
select_post_language_variant(&post, "fr", "de", true),
Some(PostLanguageVariant::Translation)
);
assert_eq!(
select_post_language_variant(&post, "fr", "de", false),
Some(PostLanguageVariant::Base)
);
post.do_not_translate = true;
assert_eq!(select_post_language_variant(&post, "fr", "de", true), None);
}
#[test]
fn starter_single_post_renderer_uses_canonical_route_and_language_links() {
let post = make_post();
@@ -591,8 +669,8 @@ mod tests {
assert_eq!(rendered.relative_path, "de/index.html");
assert!(rendered.html.contains("archive-day-group"));
assert!(rendered.html.contains("09.03.2024"));
assert!(rendered.html.contains("10.03.2024"));
assert!(rendered.html.contains("2024-03-09"));
assert!(rendered.html.contains("2024-03-10"));
assert!(rendered.html.contains("href=\"/de/2024/03/10/next\""));
}
}

View File

@@ -5,7 +5,7 @@ use std::path::Path;
use std::sync::Arc;
use crate::db::DbConnection as Connection;
use chrono::{Datelike, TimeZone, Utc};
use chrono::{Datelike, Local, TimeZone, Utc};
use rayon::prelude::*;
use serde_json::{Value, json};
@@ -17,8 +17,8 @@ use crate::model::{
TemplateKind, TemplateStatus,
};
use crate::render::{
RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path,
render_liquid_template_with_host, resolve_post_template,
PostLanguageVariant, RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path,
render_liquid_template_with_host, resolve_post_template, select_post_language_variant,
};
use crate::scripting::{CoreHost, HostApi, UnavailableHost};
use crate::util::frontmatter::{read_script_file, read_template_file, read_translation_file};
@@ -36,6 +36,8 @@ const STARTER_MENU_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/menu.liquid");
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
const STARTER_MENU_ITEMS_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/menu-items.liquid");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SitePage {
@@ -81,6 +83,7 @@ struct TemplateBundle {
#[derive(Debug, Clone)]
struct RenderPostRecord {
post: Post,
source_post_id: String,
body_markdown: String,
}
@@ -278,47 +281,64 @@ fn build_site_render_artifacts_with_mode(
canonical_post_path_by_slug(&localized_posts, &language, &main_language);
for record in &localized_posts {
let canonical_path = build_canonical_post_path(&record.post, &language, &main_language);
let relative_path = format!("{}/index.html", canonical_path.trim_start_matches('/'));
artifacts.route_manifest.push(SitePage {
language: language.clone(),
relative_path: relative_path.clone(),
url_path: canonical_path.clone(),
html: String::new(),
});
if section.is_some_and(|section| section != GenerationSection::Single) {
continue;
let mut post_paths = vec![(canonical_path, GenerationSection::Single)];
if record
.post
.categories
.iter()
.any(|category| category == "page")
{
post_paths.push((
if language == main_language {
format!("/{}", record.post.slug)
} else {
format!("/{language}/{}", record.post.slug)
},
GenerationSection::Core,
));
}
if requested_paths.is_some_and(|requested| !requested.contains(&relative_path)) {
continue;
for (url_path, route_section) in post_paths {
let relative_path = format!("{}/index.html", url_path.trim_start_matches('/'));
artifacts.route_manifest.push(SitePage {
language: language.clone(),
relative_path: relative_path.clone(),
url_path: url_path.clone(),
html: String::new(),
});
if section.is_some_and(|section| section != route_section)
|| requested_paths.is_some_and(|requested| !requested.contains(&relative_path))
{
continue;
}
let html = render_post_route(
conn,
metadata,
&language,
&main_language,
record,
&localized_posts,
&tags,
&category_settings,
&media_by_id,
&canonical_map,
&menu_items,
&post_data_json_by_id,
&bundle,
is_preview,
)?;
artifacts.pagefind_documents.push(PagefindDocument {
language: language.clone(),
relative_path: relative_path.clone(),
url_path: url_path.clone(),
html: html.clone(),
});
artifacts.pages.push(SitePage {
language: language.clone(),
relative_path,
url_path,
html,
});
}
let html = render_post_route(
conn,
metadata,
&language,
&main_language,
record,
&localized_posts,
&tags,
&category_settings,
&media_by_id,
&canonical_map,
&menu_items,
&post_data_json_by_id,
&bundle,
is_preview,
)?;
artifacts.pagefind_documents.push(PagefindDocument {
language: language.clone(),
relative_path: relative_path.clone(),
url_path: canonical_path.clone(),
html: html.clone(),
});
artifacts.pages.push(SitePage {
language: language.clone(),
relative_path,
url_path: canonical_path,
html,
});
}
}
@@ -513,47 +533,61 @@ fn load_language_posts(
) -> Result<Vec<RenderPostRecord>, Box<dyn Error + Send + Sync>> {
let mut posts = Vec::new();
for (post, body) in published_posts {
if language.eq_ignore_ascii_case(main_language) {
posts.push(RenderPostRecord {
let translation = queries::post_translation::get_post_translation_by_post_and_language(
conn, &post.id, language,
)
.ok()
.filter(|translation| {
(is_preview && translation.status == PostStatus::Draft && translation.content.is_some())
|| (!translation.file_path.trim().is_empty()
&& data_dir
.join(translation.file_path.trim_start_matches('/'))
.is_file())
});
match select_post_language_variant(post, language, main_language, translation.is_some()) {
Some(PostLanguageVariant::Base) => posts.push(RenderPostRecord {
post: post.clone(),
source_post_id: post.id.clone(),
body_markdown: body.clone(),
});
continue;
}
if let Ok(translation) =
queries::post_translation::get_post_translation_by_post_and_language(
conn, &post.id, language,
)
{
let translated_body = if is_preview && translation.status == PostStatus::Draft {
match &translation.content {
Some(content) => content.clone(),
None => read_translation_body(data_dir, &translation.file_path)?,
}
} else {
read_translation_body(data_dir, &translation.file_path)?
};
let mut translated_post = post.clone();
translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.status = translation.status.clone();
translated_post.file_path = translation.file_path.clone();
translated_post.published_at = translation.published_at.or(post.published_at);
posts.push(RenderPostRecord {
post: translated_post,
body_markdown: translated_body,
});
}),
Some(PostLanguageVariant::Translation) => {
let Some(translation) = translation else {
continue;
};
let translated_body = if is_preview && translation.status == PostStatus::Draft {
match &translation.content {
Some(content) => content.clone(),
None => read_translation_body(data_dir, &translation.file_path)?,
}
} else {
read_translation_body(data_dir, &translation.file_path)?
};
let mut translated_post = post.clone();
translated_post.id = translation.id.clone();
translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.status = translation.status.clone();
translated_post.file_path = translation.file_path.clone();
translated_post.updated_at = translation.updated_at;
translated_post.published_at = translation.published_at.or(post.published_at);
posts.push(RenderPostRecord {
post: translated_post,
source_post_id: post.id.clone(),
body_markdown: translated_body,
});
}
None => {}
}
}
posts.sort_by(|left, right| {
right
.post
.published_at
.unwrap_or(right.post.created_at)
.cmp(&left.post.published_at.unwrap_or(left.post.created_at))
.created_at
.cmp(&left.post.created_at)
.then_with(|| right.post.published_at.cmp(&left.post.published_at))
.then_with(|| left.post.slug.cmp(&right.post.slug))
});
Ok(posts)
}
@@ -607,10 +641,7 @@ fn build_language_routes(
.or_default()
.push(record.clone());
}
if let Some(timestamp) = Utc
.timestamp_millis_opt(record.post.published_at.unwrap_or(record.post.created_at))
.single()
{
if let Some(timestamp) = Local.timestamp_millis_opt(record.post.created_at).single() {
year_posts
.entry(timestamp.year())
.or_default()
@@ -791,8 +822,8 @@ fn render_list_route(
"calendar_initial_month": route.posts.first().map(|post| calendar_initial_parts(&post.post).1).unwrap_or(1),
"archive_context": route.archive_context,
"show_archive_range_heading": false,
"min_date": route.posts.last().map(|record| timestamp_parts(record.post.published_at.unwrap_or(record.post.created_at))),
"max_date": route.posts.first().map(|record| timestamp_parts(record.post.published_at.unwrap_or(record.post.created_at))),
"min_date": route.posts.last().map(|record| timestamp_parts(record.post.created_at)),
"max_date": route.posts.first().map(|record| timestamp_parts(record.post.created_at)),
"day_blocks": build_day_blocks(&route.posts, category_settings),
"is_list_page": route.current_page > 1,
"is_first_page": route.current_page == 1,
@@ -866,19 +897,19 @@ fn render_post_route(
.or_else(|| resolved.content.clone())
.unwrap_or_else(|| STARTER_SINGLE_POST_TEMPLATE.to_string());
let linked_media = queries::post_media::list_post_media_by_post(conn, &record.post.id)
let linked_media = queries::post_media::list_post_media_by_post(conn, &record.source_post_id)
.unwrap_or_default()
.into_iter()
.filter_map(|link| media_by_id.get(&link.media_id))
.map(media_context)
.collect::<Vec<_>>();
let outgoing_links =
queries::post_link::list_links_by_source(conn, &record.post.id).unwrap_or_default();
queries::post_link::list_links_by_source(conn, &record.source_post_id).unwrap_or_default();
let incoming_links =
queries::post_link::list_links_by_target(conn, &record.post.id).unwrap_or_default();
queries::post_link::list_links_by_target(conn, &record.source_post_id).unwrap_or_default();
let post_by_id = all_posts
.iter()
.map(|item| (item.post.id.clone(), item))
.map(|item| (item.source_post_id.clone(), item))
.collect::<HashMap<_, _>>();
let outgoing_link_context = outgoing_links
.iter()
@@ -1072,8 +1103,7 @@ fn build_day_blocks(
let mut current_label = String::new();
for record in posts {
let timestamp_ms = record.post.published_at.unwrap_or(record.post.created_at);
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
let Some(timestamp) = Local.timestamp_millis_opt(record.post.created_at).single() else {
continue;
};
let key = format!(
@@ -1091,13 +1121,8 @@ fn build_day_blocks(
}));
current_posts = Vec::new();
}
current_label = key.clone();
current_key = key;
current_label = format!(
"{:02}.{:02}.{:04}",
timestamp.day(),
timestamp.month(),
timestamp.year()
);
let show_title = should_show_list_title(&record.post, category_settings);
current_posts.push(json!({
"id": record.post.id,
@@ -1631,8 +1656,7 @@ fn starter_partials() -> HashMap<String, String> {
),
(
"partials/menu-items".to_string(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
.to_string(),
STARTER_MENU_ITEMS_PARTIAL.to_string(),
),
])
}
@@ -1644,8 +1668,8 @@ fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option<String> {
}
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
Utc.timestamp_millis_opt(timestamp_ms)
Local
.timestamp_millis_opt(post.created_at)
.single()
.map(|timestamp| (timestamp.year(), timestamp.month()))
.unwrap_or((1970, 1))
@@ -1670,6 +1694,22 @@ mod menu_tests {
use super::*;
use crate::engine::menu::{MenuItem, MenuItemKind};
#[test]
fn starter_menu_partial_keeps_nested_submenus_and_calendar_navigation() {
let partials = starter_partials();
let menu_items = partials.get("partials/menu-items").unwrap();
assert!(menu_items.contains("blog-menu-submenu"));
assert!(menu_items.contains("data-blog-calendar-toggle"));
assert!(menu_items.contains("data-blog-calendar-root"));
assert!(
partials
.get("partials/language-switcher")
.unwrap()
.contains("data-search-no-results")
);
}
#[test]
fn preview_request_paths_select_only_the_matching_generated_page() {
assert_eq!(preview_relative_path("/"), "index.html");

View File

@@ -247,3 +247,26 @@ fn relationships_reference_existing_entities() {
);
}
}
#[test]
fn generated_site_golden_tracks_current_bds2_rendering() {
let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/golden-generated-sites/rfc1437-sample");
let index = fs::read_to_string(directory.join("index.html")).unwrap();
let rss = fs::read_to_string(directory.join("rss.xml")).unwrap();
let atom = fs::read_to_string(directory.join("atom.xml")).unwrap();
let sitemap = fs::read_to_string(directory.join("sitemap.xml")).unwrap();
let calendar: serde_json::Value =
serde_json::from_str(&fs::read_to_string(directory.join("calendar.json")).unwrap())
.unwrap();
assert!(index.contains("class=\"blog-menu-submenu\""));
assert!(index.contains("data-search-no-results=\"Keine Ergebnisse gefunden\""));
assert!(index.contains("<span>2026-07-17</span>"));
assert!(rss.starts_with("<rss><channel><title>rfc1437 (de)</title>"));
assert!(atom.starts_with("<feed><title>rfc1437 (de)</title>"));
assert!(sitemap.contains("<loc>https://www.rfc1437.de/</loc>"));
assert!(sitemap.contains("hreflang=\"en\" href=\"https://www.rfc1437.de/en/\""));
assert!(sitemap.contains("<loc>https://www.rfc1437.de/bilderarchiv-2004/</loc>"));
assert_eq!(calendar["days"]["2026-07-17"], 3);
}

View File

@@ -11,8 +11,8 @@ use bds_core::engine::generation::{
use bds_core::engine::meta::write_category_meta_json;
use bds_core::engine::validate_site::validate_site;
use bds_core::model::{
CategorySettings, Post, PostStatus, Project, ProjectMetadata, Template, TemplateKind,
TemplateStatus,
CategorySettings, Post, PostStatus, PostTranslation, Project, ProjectMetadata, Template,
TemplateKind, TemplateStatus,
};
use tempfile::TempDir;
@@ -177,7 +177,7 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
assert!(report.written_paths.contains(&"index.html".to_string()));
assert!(report.written_paths.contains(&"calendar.json".to_string()));
assert!(report.written_paths.contains(&"rss.xml".to_string()));
assert!(report.written_paths.contains(&"feed.xml".to_string()));
assert!(!report.written_paths.contains(&"feed.xml".to_string()));
assert!(report.written_paths.contains(&"atom.xml".to_string()));
assert!(report.written_paths.contains(&"sitemap.xml".to_string()));
assert!(report.written_paths.contains(&"404.html".to_string()));
@@ -209,7 +209,7 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
assert!(dir.path().join("index.html").exists());
assert!(dir.path().join("rss.xml").exists());
assert!(dir.path().join("feed.xml").exists());
assert!(!dir.path().join("feed.xml").exists());
assert!(dir.path().join("atom.xml").exists());
assert!(dir.path().join("sitemap.xml").exists());
assert!(dir.path().join("assets/pico.min.css").exists());
@@ -217,14 +217,188 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
assert!(dir.path().join("2024/03/09/hello/index.html").exists());
let rss = std::fs::read_to_string(dir.path().join("rss.xml")).unwrap();
assert!(rss.contains("<rss version=\"2.0\""));
assert!(rss.contains("https://example.com/2024/03/09/hello"));
assert_eq!(
rss,
"<rss><channel><title>Blog (en)</title><item><title>next</title><link>https://example.com/2024/03/10/next/</link></item><item><title>hello</title><link>https://example.com/2024/03/09/hello/</link></item></channel></rss>"
);
let atom = std::fs::read_to_string(dir.path().join("atom.xml")).unwrap();
assert_eq!(
atom,
"<feed><title>Blog (en)</title><entry><title>next</title><id>https://example.com/2024/03/10/next/</id></entry><entry><title>hello</title><id>https://example.com/2024/03/09/hello/</id></entry></feed>"
);
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
assert!(sitemap.contains("https://example.com/2024/03/09/hello"));
assert!(sitemap.contains("https://example.com/category/article"));
}
#[test]
fn mixed_source_languages_use_the_main_language_at_canonical_urls() {
let (db, dir) = setup();
let mut metadata = make_metadata();
metadata.main_language = Some("de".into());
metadata.blog_languages = vec!["de".into(), "en".into()];
bds_core::engine::meta::write_project_json(dir.path(), &metadata).unwrap();
insert_template(
db.conn(),
&Template {
id: "template-post".into(),
project_id: "p1".into(),
slug: "post".into(),
title: "Post".into(),
kind: TemplateKind::Post,
enabled: true,
version: 1,
file_path: String::new(),
status: TemplateStatus::Published,
content: Some("ID={{ post.id }} {{ post.content }}".into()),
created_at: 1,
updated_at: 1,
},
)
.unwrap();
let mut english_source = make_post("english-source", 1_710_000_000_000);
english_source.language = Some("en".into());
write_published_snapshot(&dir, &mut english_source, "English source body");
bds_core::db::queries::post::insert_post(db.conn(), &english_source).unwrap();
let german_translation = PostTranslation {
id: "translation-english-de".into(),
project_id: "p1".into(),
translation_for: english_source.id.clone(),
language: "de".into(),
title: "Deutsche Übersetzung".into(),
excerpt: None,
content: None,
status: PostStatus::Published,
file_path: "posts/english-source.de.md".into(),
checksum: None,
created_at: english_source.created_at,
updated_at: english_source.updated_at,
published_at: english_source.published_at,
};
bds_core::db::queries::post_translation::insert_post_translation(
db.conn(),
&german_translation,
)
.unwrap();
std::fs::write(
dir.path().join(&german_translation.file_path),
bds_core::util::frontmatter::write_translation_file(
&german_translation,
"Deutscher Übersetzungstext",
),
)
.unwrap();
let mut german_without_translation = make_post("german-fallback", 1_710_172_800_000);
german_without_translation.language = Some("de".into());
german_without_translation.title = "Deutscher Fallback".into();
let mut german_source = make_post("german-source", 1_710_086_400_000);
german_source.language = Some("de".into());
german_source.title = "Deutsche Quelle".into();
write_published_snapshot(&dir, &mut german_source, "Deutscher Quelltext");
bds_core::db::queries::post::insert_post(db.conn(), &german_source).unwrap();
let english_translation = PostTranslation {
id: "translation-german-en".into(),
project_id: "p1".into(),
translation_for: german_source.id.clone(),
language: "en".into(),
title: "English translation".into(),
excerpt: None,
content: None,
status: PostStatus::Published,
file_path: "posts/german-source.en.md".into(),
checksum: None,
created_at: german_source.created_at,
updated_at: german_source.updated_at,
published_at: german_source.published_at,
};
bds_core::db::queries::post_translation::insert_post_translation(
db.conn(),
&english_translation,
)
.unwrap();
std::fs::write(
dir.path().join(&english_translation.file_path),
bds_core::util::frontmatter::write_translation_file(
&english_translation,
"English translation body",
),
)
.unwrap();
let mut german_private = make_post("german-private", 1_710_259_200_000);
german_private.language = Some("de".into());
german_private.do_not_translate = true;
let output = dir.path().join("html");
let sources = vec![
PublishedPostSource {
post: english_source,
body_markdown: "English source body".into(),
},
PublishedPostSource {
post: german_source,
body_markdown: "Deutscher Quelltext".into(),
},
PublishedPostSource {
post: german_without_translation,
body_markdown: "Unübersetzter Quelltext".into(),
},
PublishedPostSource {
post: german_private,
body_markdown: "Nur auf Deutsch".into(),
},
];
generate_starter_site(db.conn(), &output, "p1", &metadata, &sources, "de").unwrap();
let canonical_english_source =
std::fs::read_to_string(output.join("2024/03/09/english-source/index.html")).unwrap();
let localized_english_source =
std::fs::read_to_string(output.join("en/2024/03/09/english-source/index.html")).unwrap();
let canonical_german_source =
std::fs::read_to_string(output.join("2024/03/10/german-source/index.html")).unwrap();
let localized_german_source =
std::fs::read_to_string(output.join("en/2024/03/10/german-source/index.html")).unwrap();
let localized_fallback =
std::fs::read_to_string(output.join("en/2024/03/11/german-fallback/index.html")).unwrap();
assert!(canonical_english_source.contains("Deutscher Übersetzungstext"));
assert!(canonical_english_source.contains("ID=translation-english-de"));
assert!(localized_english_source.contains("English source body"));
assert!(localized_english_source.contains("ID=post-english-source"));
assert!(canonical_german_source.contains("Deutscher Quelltext"));
assert!(localized_german_source.contains("English translation body"));
assert!(localized_fallback.contains("Unübersetzter Quelltext"));
assert!(
output
.join("2024/03/12/german-private/index.html")
.is_file()
);
assert!(
!output
.join("en/2024/03/12/german-private/index.html")
.exists()
);
let sitemap = std::fs::read_to_string(output.join("sitemap.xml")).unwrap();
assert!(sitemap.contains("https://example.com/2024/03/09/english-source"));
assert!(sitemap.contains("https://example.com/en/2024/03/09/english-source"));
assert!(sitemap.contains("https://example.com/2024/03/10/german-source"));
assert!(sitemap.contains("https://example.com/en/2024/03/10/german-source"));
assert!(sitemap.contains("https://example.com/2024/03/12/german-private"));
assert!(!sitemap.contains("https://example.com/en/2024/03/12/german-private"));
let main_rss = std::fs::read_to_string(output.join("rss.xml")).unwrap();
let english_rss = std::fs::read_to_string(output.join("en/rss.xml")).unwrap();
assert!(main_rss.contains("<title>english-source</title>"));
assert!(!main_rss.contains("<title>Deutsche Übersetzung</title>"));
assert!(english_rss.contains("<title>english-source</title>"));
assert!(english_rss.contains("<title>English translation</title>"));
assert!(!english_rss.contains("<title>Deutscher Fallback</title>"));
}
#[test]
fn section_generation_reports_its_urls_and_defers_pagefind() {
let (db, dir) = setup();
@@ -345,7 +519,8 @@ fn multilingual_generation_writes_language_aware_atom_and_sitemap_routes() {
assert!(report.written_paths.contains(&"en/atom.xml".to_string()));
assert!(report.written_paths.contains(&"en/rss.xml".to_string()));
assert!(report.written_paths.contains(&"en/sitemap.xml".to_string()));
assert!(!report.written_paths.contains(&"en/sitemap.xml".to_string()));
assert!(!report.written_paths.contains(&"feed.xml".to_string()));
assert!(report.written_paths.contains(&"en/404.html".to_string()));
assert!(
report
@@ -354,19 +529,79 @@ fn multilingual_generation_writes_language_aware_atom_and_sitemap_routes() {
);
let atom = std::fs::read_to_string(dir.path().join("en/atom.xml")).unwrap();
assert!(
atom.contains("<link href=\"https://example.com/en/\" rel=\"alternate\" />")
|| atom.contains("<link href=\"https://example.com/en\" rel=\"alternate\" />")
);
assert!(atom.contains("<link href=\"https://example.com/en/atom.xml\" rel=\"self\" />"));
assert!(atom.starts_with("<feed><title>Blog (en)</title>"));
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
assert!(sitemap.contains("hreflang=\"de\" href=\"https://example.com/\""));
assert!(sitemap.contains("hreflang=\"en\" href=\"https://example.com/en\""));
assert!(sitemap.contains("hreflang=\"en\" href=\"https://example.com/en/\""));
assert!(sitemap.contains("hreflang=\"x-default\" href=\"https://example.com/\""));
assert!(sitemap.contains("https://example.com/category/article"));
}
#[test]
fn page_category_posts_also_generate_flat_routes() {
let (db, dir) = setup();
let metadata = make_metadata();
let mut page = make_post("about", 1_710_000_000_000);
page.categories = vec!["page".into()];
let posts = vec![PublishedPostSource {
post: page,
body_markdown: "About this site".into(),
}];
let report =
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
assert!(
report
.written_paths
.contains(&"about/index.html".to_string())
);
assert!(dir.path().join("about/index.html").is_file());
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
assert!(sitemap.contains("<loc>https://example.com/about/</loc>"));
}
#[test]
fn page_aliases_belong_to_core_while_dated_posts_belong_to_single() {
let (db, dir) = setup();
let metadata = make_metadata();
let mut page = make_post("about", 1_710_000_000_000);
page.categories = vec!["page".into()];
let posts = vec![PublishedPostSource {
post: page,
body_markdown: "About this site".into(),
}];
let single = render_site_section_with_progress(
db.conn(),
dir.path(),
"p1",
&metadata,
&posts,
GenerationSection::Single,
|_current, _total, _url| {},
|| false,
)
.unwrap();
assert_eq!(single.written_paths, vec!["2024/03/09/about/index.html"]);
assert!(!dir.path().join("about/index.html").exists());
let core = render_site_section_with_progress(
db.conn(),
dir.path(),
"p1",
&metadata,
&posts,
GenerationSection::Core,
|_current, _total, _url| {},
|| false,
)
.unwrap();
assert!(core.written_paths.contains(&"about/index.html".to_string()));
assert!(dir.path().join("about/index.html").is_file());
}
#[test]
fn generation_respects_category_list_settings_and_writes_bundled_images() {
let (db, dir) = setup();
@@ -457,12 +692,12 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() {
std::fs::read_to_string(dir.path().join("category/featured/index.html")).unwrap();
assert!(featured_html.contains("FEATURED:[Featured Post|false]"));
let feed = std::fs::read_to_string(dir.path().join("feed.xml")).unwrap();
assert!(!feed.contains("hidden-post"));
assert!(feed.contains("featured-post"));
let rss = std::fs::read_to_string(dir.path().join("rss.xml")).unwrap();
assert!(rss.contains("hidden-post"));
assert!(rss.contains("featured-post"));
let calendar = std::fs::read_to_string(dir.path().join("calendar.json")).unwrap();
assert!(!calendar.contains("2024-03-09"));
assert!(calendar.contains("2024-03-09"));
assert!(calendar.contains("2024-03-10"));
}
@@ -484,7 +719,7 @@ fn generation_engine_skips_unchanged_outputs_on_second_run() {
assert!(second.skipped_paths.contains(&"index.html".to_string()));
assert!(second.skipped_paths.contains(&"calendar.json".to_string()));
assert!(second.skipped_paths.contains(&"rss.xml".to_string()));
assert!(second.skipped_paths.contains(&"feed.xml".to_string()));
assert!(!second.skipped_paths.contains(&"feed.xml".to_string()));
assert!(
second
.skipped_paths
@@ -506,12 +741,12 @@ fn site_validation_detects_stale_and_missing_outputs() {
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
std::fs::write(dir.path().join("index.html"), "tampered").unwrap();
std::fs::remove_file(dir.path().join("feed.xml")).unwrap();
std::fs::remove_file(dir.path().join("atom.xml")).unwrap();
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(report.stale_pages.contains(&"index.html".to_string()));
assert!(report.missing_pages.contains(&"feed.xml".to_string()));
assert!(report.missing_pages.contains(&"atom.xml".to_string()));
assert!(report.extra_pages.is_empty());
}
@@ -529,7 +764,7 @@ fn apply_validation_repairs_core_section_outputs() {
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
std::fs::write(dir.path().join("index.html"), "tampered").unwrap();
std::fs::remove_file(dir.path().join("feed.xml")).unwrap();
std::fs::remove_file(dir.path().join("atom.xml")).unwrap();
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
let sections = sections_from_validation_report(&report);

View File

@@ -111,20 +111,20 @@ fn generated_write_rewrites_stale_file_even_when_db_hash_matches() {
}
#[test]
fn core_generation_paths_include_language_prefixed_variants() {
fn core_generation_paths_match_bds2_outputs() {
let paths = build_core_generation_paths("en", &["en".into(), "de".into(), "fr".into()]);
assert!(paths.contains(&"index.html".to_string()));
assert!(paths.contains(&"404.html".to_string()));
assert!(paths.contains(&"sitemap.xml".to_string()));
assert!(paths.contains(&"rss.xml".to_string()));
assert!(paths.contains(&"feed.xml".to_string()));
assert!(!paths.contains(&"feed.xml".to_string()));
assert!(paths.contains(&"atom.xml".to_string()));
assert!(paths.contains(&"calendar.json".to_string()));
assert!(paths.contains(&"de/index.html".to_string()));
assert!(paths.contains(&"de/404.html".to_string()));
assert!(paths.contains(&"de/sitemap.xml".to_string()));
assert!(!paths.contains(&"de/sitemap.xml".to_string()));
assert!(paths.contains(&"de/rss.xml".to_string()));
assert!(paths.contains(&"de/feed.xml".to_string()));
assert!(!paths.contains(&"de/feed.xml".to_string()));
assert!(paths.contains(&"de/atom.xml".to_string()));
assert!(paths.contains(&"fr/index.html".to_string()));
}
@@ -138,6 +138,7 @@ fn calendar_json_groups_posts_by_year_month_day() {
];
let json = build_calendar_json(&posts).unwrap();
assert!(!json.contains('\n'));
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["years"]["2024"], 3);
@@ -146,3 +147,15 @@ fn calendar_json_groups_posts_by_year_month_day() {
assert_eq!(parsed["days"]["2024-03-09"], 2);
assert_eq!(parsed["days"]["2024-04-09"], 1);
}
#[test]
fn calendar_json_uses_created_at_instead_of_published_at() {
let mut post = make_post("a", 1_710_000_000_000);
post.published_at = Some(1_712_678_400_000);
let json = build_calendar_json(&[post]).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["days"]["2024-03-09"], 1);
assert!(parsed["days"].get("2024-04-09").is_none());
}

View File

@@ -3,19 +3,23 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Content-type: matter-transport/sentient-life-form</title>
<title>Esmeralda</title>
<link rel="stylesheet" href="/assets/pico.slate.min.css" />
<link rel="stylesheet" href="/assets/lightbox.min.css" />
<link rel="stylesheet" href="/assets/highlight.min.css" />
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
<link rel="stylesheet" href="/assets/bds.css" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="/atom.xml" />
<link rel="alternate" hreflang="de" href="/2005/11/13/esmeralda" />
<link rel="alternate" hreflang="es" href="/2005/11/13/esmeralda/" />
<link rel="alternate" hreflang="de" href="/2005/11/13/esmeralda/" />
<link rel="alternate" hreflang="en" href="/en/2005/11/13/esmeralda/" />
<script defer src="/assets/highlight.min.js"></script>
<script defer src="/assets/code-enhancements.js"></script>
<script defer src="/assets/d3.layout.cloud.js"></script>
@@ -27,20 +31,19 @@
<link rel="stylesheet" href="/pagefind/pagefind-ui.css" />
<script defer src="/pagefind/pagefind-ui.js"></script>
</head>
<body>
<main>
<nav class="language-switcher" aria-label="Sprache">
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="de">🇩🇪</span>
<a class="language-switcher-badge" href="/en" data-lang-prefix="/en" title="en">🇬🇧</a>
<div class="blog-search-widget" aria-label="Seitensuche">
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="Seitensuche">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
@@ -49,7 +52,7 @@
</svg>
</button>
<div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="Suchen..."></div>
<div id="blog-search" data-blog-search-root data-search-placeholder="Suchen..." data-search-no-results="Keine Ergebnisse gefunden"></div>
</div>
</div>
</nav>
@@ -64,167 +67,167 @@
<h1>Esmeralda</h1>
<nav class="blog-menu">
<ul class="blog-menu-list">
<li class="blog-menu-item">
<a class="blog-menu-link" href="/">Home</a>
</li>
<li class="blog-menu-item blog-menu-item-with-children">
<span class="blog-menu-link">Bilderarchiv</span>
<div class="blog-menu-submenu">
<ul class="blog-menu-list">
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2022/">Bilderarchiv 2022</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2016/">Bilderarchiv 2016</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2015/">Bilderarchiv 2015</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2014/">Bilderarchiv 2014</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2013/">Bilderarchiv 2013</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2012/">Bilderarchiv 2012</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2011/">Bilderarchiv 2011</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2010/">Bilderarchiv 2010</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2009/">Bilderarchiv 2009</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2008/">Bilderarchiv 2008</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2005/">Bilderarchiv 2005</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2004/">Bilderarchiv 2004</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2003/">Bilderarchiv 2003</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2022/">Bilderarchiv 2022</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2016/">Bilderarchiv 2016</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2015/">Bilderarchiv 2015</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2014/">Bilderarchiv 2014</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2013/">Bilderarchiv 2013</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2012/">Bilderarchiv 2012</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2011/">Bilderarchiv 2011</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2010/">Bilderarchiv 2010</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2009/">Bilderarchiv 2009</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2008/">Bilderarchiv 2008</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2005/">Bilderarchiv 2005</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2004/">Bilderarchiv 2004</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2003/">Bilderarchiv 2003</a>
</li>
</ul>
</div>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/picture/">Fotoblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/tag-cloud/">Tag Cloud</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/spielelog/">Spieleblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/kochbuch/">Kochbuch</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/wiki/">Wiki</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/impressum/">Impressum</a>
</li>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/picture/">Fotoblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/tag-cloud/">Tag Cloud</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/spielelog/">Spieleblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/kochbuch/">Kochbuch</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/wiki/">Wiki</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/impressum/">Impressum</a>
</li>
<li class="blog-menu-item blog-menu-calendar">
<button
type="button"
@@ -252,7 +255,7 @@
hidden
>
<header class="blog-calendar-header">
<strong>Archivkalender</strong>
<strong>Archiv</strong>
<button
type="button"
class="blog-calendar-close"
@@ -269,41 +272,18 @@
</div>
</section>
</li>
</ul>
</nav>
<div class="single-post-taxonomy" aria-label="Taxonomie">
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/picture/">picture</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/fotografie/">fotografie</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/makro/">makro</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/natur/">natur</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/spinne/">spinne</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/tiere/">tiere</a>
</div>
<article class="single-post" data-template="single-post" data-pagefind-body>
<article class="single-post blog-post" data-template="single-post" data-pagefind-body>
<div class="post"><h3><a href="/media/esmeralda/">Esmeralda</a></h3>
<p><img src="/media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg" alt="Esmeralda"></p>
</div>
<p><img src="/media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg" alt="Esmeralda" /></p></div>
</article>
</main>
</body>
</html>
</html>

View File

@@ -3,17 +3,21 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Content-type: matter-transport/sentient-life-form</title>
<title>cmux — Das Terminal für Multitasking</title>
<link rel="stylesheet" href="/assets/pico.slate.min.css" />
<link rel="stylesheet" href="/assets/lightbox.min.css" />
<link rel="stylesheet" href="/assets/highlight.min.css" />
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
<link rel="stylesheet" href="/assets/bds.css" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="/atom.xml" />
<link rel="alternate" hreflang="de" href="/2026/03/13/cmux-das-terminal-fur-multitasking/" />
<link rel="alternate" hreflang="en" href="/en/2026/03/13/cmux-das-terminal-fur-multitasking/" />
<script defer src="/assets/highlight.min.js"></script>
<script defer src="/assets/code-enhancements.js"></script>
<script defer src="/assets/d3.layout.cloud.js"></script>
@@ -25,20 +29,19 @@
<link rel="stylesheet" href="/pagefind/pagefind-ui.css" />
<script defer src="/pagefind/pagefind-ui.js"></script>
</head>
<body>
<main>
<nav class="language-switcher" aria-label="Sprache">
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="de">🇩🇪</span>
<a class="language-switcher-badge" href="/en" data-lang-prefix="/en" title="en">🇬🇧</a>
<div class="blog-search-widget" aria-label="Seitensuche">
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="Seitensuche">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
@@ -47,7 +50,7 @@
</svg>
</button>
<div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="Suchen..."></div>
<div id="blog-search" data-blog-search-root data-search-placeholder="Suchen..." data-search-no-results="Keine Ergebnisse gefunden"></div>
</div>
</div>
</nav>
@@ -62,167 +65,167 @@
<h1>cmux — Das Terminal für Multitasking</h1>
<nav class="blog-menu">
<ul class="blog-menu-list">
<li class="blog-menu-item">
<a class="blog-menu-link" href="/">Home</a>
</li>
<li class="blog-menu-item blog-menu-item-with-children">
<span class="blog-menu-link">Bilderarchiv</span>
<div class="blog-menu-submenu">
<ul class="blog-menu-list">
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2022/">Bilderarchiv 2022</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2016/">Bilderarchiv 2016</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2015/">Bilderarchiv 2015</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2014/">Bilderarchiv 2014</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2013/">Bilderarchiv 2013</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2012/">Bilderarchiv 2012</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2011/">Bilderarchiv 2011</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2010/">Bilderarchiv 2010</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2009/">Bilderarchiv 2009</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2008/">Bilderarchiv 2008</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2005/">Bilderarchiv 2005</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2004/">Bilderarchiv 2004</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2003/">Bilderarchiv 2003</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2022/">Bilderarchiv 2022</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2016/">Bilderarchiv 2016</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2015/">Bilderarchiv 2015</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2014/">Bilderarchiv 2014</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2013/">Bilderarchiv 2013</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2012/">Bilderarchiv 2012</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2011/">Bilderarchiv 2011</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2010/">Bilderarchiv 2010</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2009/">Bilderarchiv 2009</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2008/">Bilderarchiv 2008</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2005/">Bilderarchiv 2005</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2004/">Bilderarchiv 2004</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2003/">Bilderarchiv 2003</a>
</li>
</ul>
</div>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/picture/">Fotoblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/tag-cloud/">Tag Cloud</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/spielelog/">Spieleblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/kochbuch/">Kochbuch</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/wiki/">Wiki</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/impressum/">Impressum</a>
</li>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/picture/">Fotoblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/tag-cloud/">Tag Cloud</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/spielelog/">Spieleblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/kochbuch/">Kochbuch</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/wiki/">Wiki</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/impressum/">Impressum</a>
</li>
<li class="blog-menu-item blog-menu-calendar">
<button
type="button"
@@ -250,7 +253,7 @@
hidden
>
<header class="blog-calendar-header">
<strong>Archivkalender</strong>
<strong>Archiv</strong>
<button
type="button"
class="blog-calendar-close"
@@ -267,41 +270,40 @@
</div>
</section>
</li>
</ul>
</nav>
<div class="single-post-taxonomy" aria-label="Taxonomie">
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/aside/">aside</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/programmierung/">programmierung</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/mac-os-x/">mac-os-x</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/sysadmin/">sysadmin</a>
</div>
<article class="single-post" data-template="single-post" data-pagefind-body>
<div class="post"><p><a href="https://www.cmux.dev/de">cmux — Das Terminal für Multitasking</a> ist ein ziemlich geniales Terminal-Programm für die gerade durch Agentic Coding wieder verstärkt kommenden CLI Workflows. Und was mich begeistert: es hat eine saubere Persistierung von offenen Workspaces in denen man dann mehrere Tabs haben kann, so dass ich meine Arbeitsumgebung nicht ständig offen lassen muss. Ich habe immer gerne diverse Verzeichnisse direkt offen, weil ich zwischen mehreren hin und her wechsel wärend der Programmierung, das läuft mit CMUX deutlich besser als mit allen anderen.</p>
</div>
<article class="single-post blog-post" data-template="single-post" data-pagefind-body>
<div class="post"><p><a href="https://www.cmux.dev/de">cmux — Das Terminal für Multitasking</a> ist ein ziemlich geniales Terminal-Programm für die gerade durch Agentic Coding wieder verstärkt kommenden CLI Workflows. Und was mich begeistert: es hat eine saubere Persistierung von offenen Workspaces in denen man dann mehrere Tabs haben kann, so dass ich meine Arbeitsumgebung nicht ständig offen lassen muss. Ich habe immer gerne diverse Verzeichnisse direkt offen, weil ich zwischen mehreren hin und her wechsel wärend der Programmierung, das läuft mit CMUX deutlich besser als mit allen anderen.</p></div>
</article>
<div class="single-post-backlinks" aria-label="Rückverweise">
<span class="single-post-backlinks-label">Verlinkt von</span>
<a class="single-post-taxonomy-bubble single-post-backlink-bubble" href="/2026/03/13/ghostty">ghostty</a>
<a class="single-post-taxonomy-bubble single-post-backlink-bubble" href="/2026/06/30/supacode/">supacode</a>
</div>
</main>
</body>
</html>
</html>

View File

@@ -3,17 +3,21 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Content-type: matter-transport/sentient-life-form</title>
<title>Ghostty</title>
<link rel="stylesheet" href="/assets/pico.slate.min.css" />
<link rel="stylesheet" href="/assets/lightbox.min.css" />
<link rel="stylesheet" href="/assets/highlight.min.css" />
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
<link rel="stylesheet" href="/assets/bds.css" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="/atom.xml" />
<link rel="alternate" hreflang="de" href="/2026/03/13/ghostty/" />
<link rel="alternate" hreflang="en" href="/en/2026/03/13/ghostty/" />
<script defer src="/assets/highlight.min.js"></script>
<script defer src="/assets/code-enhancements.js"></script>
<script defer src="/assets/d3.layout.cloud.js"></script>
@@ -25,20 +29,19 @@
<link rel="stylesheet" href="/pagefind/pagefind-ui.css" />
<script defer src="/pagefind/pagefind-ui.js"></script>
</head>
<body>
<main>
<nav class="language-switcher" aria-label="Sprache">
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="de">🇩🇪</span>
<a class="language-switcher-badge" href="/en" data-lang-prefix="/en" title="en">🇬🇧</a>
<div class="blog-search-widget" aria-label="Seitensuche">
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="Seitensuche">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
@@ -47,7 +50,7 @@
</svg>
</button>
<div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="Suchen..."></div>
<div id="blog-search" data-blog-search-root data-search-placeholder="Suchen..." data-search-no-results="Keine Ergebnisse gefunden"></div>
</div>
</div>
</nav>
@@ -62,167 +65,167 @@
<h1>Ghostty</h1>
<nav class="blog-menu">
<ul class="blog-menu-list">
<li class="blog-menu-item">
<a class="blog-menu-link" href="/">Home</a>
</li>
<li class="blog-menu-item blog-menu-item-with-children">
<span class="blog-menu-link">Bilderarchiv</span>
<div class="blog-menu-submenu">
<ul class="blog-menu-list">
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2022/">Bilderarchiv 2022</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2016/">Bilderarchiv 2016</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2015/">Bilderarchiv 2015</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2014/">Bilderarchiv 2014</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2013/">Bilderarchiv 2013</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2012/">Bilderarchiv 2012</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2011/">Bilderarchiv 2011</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2010/">Bilderarchiv 2010</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2009/">Bilderarchiv 2009</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2008/">Bilderarchiv 2008</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2005/">Bilderarchiv 2005</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2004/">Bilderarchiv 2004</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2003/">Bilderarchiv 2003</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2022/">Bilderarchiv 2022</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2016/">Bilderarchiv 2016</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2015/">Bilderarchiv 2015</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2014/">Bilderarchiv 2014</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2013/">Bilderarchiv 2013</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2012/">Bilderarchiv 2012</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2011/">Bilderarchiv 2011</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2010/">Bilderarchiv 2010</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2009/">Bilderarchiv 2009</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2008/">Bilderarchiv 2008</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2005/">Bilderarchiv 2005</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2004/">Bilderarchiv 2004</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2003/">Bilderarchiv 2003</a>
</li>
</ul>
</div>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/picture/">Fotoblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/tag-cloud/">Tag Cloud</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/spielelog/">Spieleblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/kochbuch/">Kochbuch</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/wiki/">Wiki</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/impressum/">Impressum</a>
</li>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/picture/">Fotoblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/tag-cloud/">Tag Cloud</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/spielelog/">Spieleblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/kochbuch/">Kochbuch</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/wiki/">Wiki</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/impressum/">Impressum</a>
</li>
<li class="blog-menu-item blog-menu-calendar">
<button
type="button"
@@ -250,7 +253,7 @@
hidden
>
<header class="blog-calendar-header">
<strong>Archivkalender</strong>
<strong>Archiv</strong>
<button
type="button"
class="blog-calendar-close"
@@ -267,34 +270,33 @@
</div>
</section>
</li>
</ul>
</nav>
<div class="single-post-taxonomy" aria-label="Taxonomie">
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/aside/">aside</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/programmierung/">programmierung</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/sysadmin/">sysadmin</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/mac-os-x/">mac-os-x</a>
</div>
<article class="single-post" data-template="single-post" data-pagefind-body>
<div class="post"><p><a href="https://ghostty.org/">Ghostty</a> ist die Basis, auf der <a href="/2026/03/13/cmux-das-terminal-fur-multitasking">cmux — Das Terminal für Multitasking</a> aufgebaut wurde. Generell ebenfalls ein sehr nettes Terminal, das auch deutlich schneller reagiert als das Standardterminal und auch schon sehr gut funktioniert. Was mir nicht gefiel, war dass Tabs nicht automatisch in den passenden Pfaden wieder neu geöffnet wurden, wenn das Programm beendet wurde. Ich habe einfach zu lange persistente Sessions, die ich immer wieder aufgreife. Das macht cmux einfach besser.</p>
</div>
<article class="single-post blog-post" data-template="single-post" data-pagefind-body>
<div class="post"><p><a href="https://ghostty.org/">Ghostty</a> ist die Basis, auf der <a href="/en/2026/03/13/cmux-das-terminal-fur-multitasking/">cmux — Das Terminal für Multitasking</a> aufgebaut wurde. Generell ebenfalls ein sehr nettes Terminal, das auch deutlich schneller reagiert als das Standardterminal und auch schon sehr gut funktioniert. Was mir nicht gefiel, war dass Tabs nicht automatisch in den passenden Pfaden wieder neu geöffnet wurden, wenn das Programm beendet wurde. Ich habe einfach zu lange persistente Sessions, die ich immer wieder aufgreife. Das macht cmux einfach besser.</p></div>
</article>
</main>
</body>
</html>
</html>

View File

@@ -166,4 +166,4 @@ main { display: grid; gap: 1rem; }
.blog-search-panel .pagefind-ui__result-excerpt { font-size: .78rem; color: var(--pico-muted-color, var(--muted-color)); }
.blog-search-panel .pagefind-ui__result-excerpt mark { background-color: var(--pico-primary-focus, rgba(255,223,0,.35)); color: inherit; }
.blog-search-panel .pagefind-ui__button { color: var(--pico-primary, var(--primary)); background: none; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); border-radius: .2rem; font-size: .78rem; cursor: pointer; }
.blog-search-panel .pagefind-ui__button:hover { border-color: var(--pico-primary, var(--primary)); }
.blog-search-panel .pagefind-ui__button:hover { border-color: var(--pico-primary, var(--primary)); }

View File

@@ -15,11 +15,12 @@
}
initialized = true;
var placeholder = root.getAttribute('data-search-placeholder') || 'Search...';
var zeroResults = root.getAttribute('data-search-no-results') || 'No results found';
new PagefindUI({
element: root,
showSubResults: true,
showImages: false,
translations: { placeholder: placeholder }
translations: { placeholder: placeholder, zero_results: zeroResults }
});
var input = root.querySelector('input');
if (input) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,19 +3,19 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Content-type: matter-transport/sentient-life-form</title>
<title>Esmeralda</title>
<link rel="stylesheet" href="/assets/pico.slate.min.css" />
<link rel="stylesheet" href="/assets/lightbox.min.css" />
<link rel="stylesheet" href="/assets/highlight.min.css" />
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
<link rel="stylesheet" href="/assets/bds.css" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/en/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="/en/atom.xml" />
<link rel="alternate" hreflang="en" href="/en/2005/11/13/esmeralda" />
<link rel="alternate" hreflang="en" href="/2005/11/13/esmeralda/" />
<script defer src="/assets/highlight.min.js"></script>
<script defer src="/assets/code-enhancements.js"></script>
<script defer src="/assets/d3.layout.cloud.js"></script>
@@ -27,20 +27,19 @@
<link rel="stylesheet" href="/en/pagefind/pagefind-ui.css" />
<script defer src="/en/pagefind/pagefind-ui.js"></script>
</head>
<body>
<main>
<nav class="language-switcher" aria-label="Language">
<a class="language-switcher-badge" href="/en/" data-lang-prefix="" title="de">🇩🇪</a>
<a class="language-switcher-badge" href="/" data-lang-prefix="" title="de">🇩🇪</a>
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="en">🇬🇧</span>
<div class="blog-search-widget" aria-label="Site search">
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="Site search">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
@@ -49,7 +48,7 @@
</svg>
</button>
<div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="Search..."></div>
<div id="blog-search" data-blog-search-root data-search-placeholder="Search..." data-search-no-results="No results found"></div>
</div>
</div>
</nav>
@@ -64,167 +63,167 @@
<h1>Esmeralda</h1>
<nav class="blog-menu">
<ul class="blog-menu-list">
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/">Home</a>
<a class="blog-menu-link" href="/">Home</a>
</li>
<li class="blog-menu-item blog-menu-item-with-children">
<span class="blog-menu-link">Bilderarchiv</span>
<div class="blog-menu-submenu">
<ul class="blog-menu-list">
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2022/">Bilderarchiv 2022</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2016/">Bilderarchiv 2016</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2015/">Bilderarchiv 2015</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2014/">Bilderarchiv 2014</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2013/">Bilderarchiv 2013</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2012/">Bilderarchiv 2012</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2011/">Bilderarchiv 2011</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2010/">Bilderarchiv 2010</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2009/">Bilderarchiv 2009</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2008/">Bilderarchiv 2008</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2005/">Bilderarchiv 2005</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2004/">Bilderarchiv 2004</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2003/">Bilderarchiv 2003</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2022/">Bilderarchiv 2022</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2016/">Bilderarchiv 2016</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2015/">Bilderarchiv 2015</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2014/">Bilderarchiv 2014</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2013/">Bilderarchiv 2013</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2012/">Bilderarchiv 2012</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2011/">Bilderarchiv 2011</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2010/">Bilderarchiv 2010</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2009/">Bilderarchiv 2009</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2008/">Bilderarchiv 2008</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2005/">Bilderarchiv 2005</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2004/">Bilderarchiv 2004</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2003/">Bilderarchiv 2003</a>
</li>
</ul>
</div>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/category/picture/">Fotoblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/tag-cloud/">Tag Cloud</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/category/spielelog/">Spieleblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/category/kochbuch/">Kochbuch</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/wiki/">Wiki</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/impressum/">Impressum</a>
</li>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/picture/">Fotoblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/tag-cloud/">Tag Cloud</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/spielelog/">Spieleblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/kochbuch/">Kochbuch</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/wiki/">Wiki</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/impressum/">Impressum</a>
</li>
<li class="blog-menu-item blog-menu-calendar">
<button
type="button"
@@ -269,41 +268,40 @@
</div>
</section>
</li>
</ul>
</nav>
<div class="single-post-taxonomy" aria-label="Taxonomy">
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/en/category/picture/">picture</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/en/tag/fotografie/">fotografie</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/en/tag/makro/">makro</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/en/tag/natur/">natur</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/en/tag/spinne/">spinne</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/en/tag/tiere/">tiere</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/picture/">picture</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/fotografie/">fotografie</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/makro/">makro</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/natur/">natur</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/spinne/">spinne</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/tiere/">tiere</a>
</div>
<article class="single-post" data-template="single-post" data-pagefind-body>
<article class="single-post blog-post" data-template="single-post" data-pagefind-body>
<div class="post"><h3><a href="/media/esmeralda/">Esmeralda</a></h3>
<p><img src="/media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg" alt="Esmeralda"></p>
</div>
<p><img src="/media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg" alt="Esmeralda" /></p></div>
</article>
</main>
</body>
</html>
</html>

View File

@@ -3,19 +3,19 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Content-type: matter-transport/sentient-life-form</title>
<title>cmux — The Terminal for Multitasking</title>
<link rel="stylesheet" href="/assets/pico.slate.min.css" />
<link rel="stylesheet" href="/assets/lightbox.min.css" />
<link rel="stylesheet" href="/assets/highlight.min.css" />
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
<link rel="stylesheet" href="/assets/bds.css" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/en/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="/en/atom.xml" />
<link rel="alternate" hreflang="en" href="/en/2026/03/13/cmux-das-terminal-fur-multitasking" />
<link rel="alternate" hreflang="en" href="/2026/03/13/cmux-das-terminal-fur-multitasking/" />
<script defer src="/assets/highlight.min.js"></script>
<script defer src="/assets/code-enhancements.js"></script>
<script defer src="/assets/d3.layout.cloud.js"></script>
@@ -27,20 +27,19 @@
<link rel="stylesheet" href="/en/pagefind/pagefind-ui.css" />
<script defer src="/en/pagefind/pagefind-ui.js"></script>
</head>
<body>
<main>
<nav class="language-switcher" aria-label="Language">
<a class="language-switcher-badge" href="/en/" data-lang-prefix="" title="de">🇩🇪</a>
<a class="language-switcher-badge" href="/" data-lang-prefix="" title="de">🇩🇪</a>
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="en">🇬🇧</span>
<div class="blog-search-widget" aria-label="Site search">
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="Site search">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
@@ -49,7 +48,7 @@
</svg>
</button>
<div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="Search..."></div>
<div id="blog-search" data-blog-search-root data-search-placeholder="Search..." data-search-no-results="No results found"></div>
</div>
</div>
</nav>
@@ -64,167 +63,167 @@
<h1>cmux — The Terminal for Multitasking</h1>
<nav class="blog-menu">
<ul class="blog-menu-list">
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/">Home</a>
<a class="blog-menu-link" href="/">Home</a>
</li>
<li class="blog-menu-item blog-menu-item-with-children">
<span class="blog-menu-link">Bilderarchiv</span>
<div class="blog-menu-submenu">
<ul class="blog-menu-list">
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2022/">Bilderarchiv 2022</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2016/">Bilderarchiv 2016</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2015/">Bilderarchiv 2015</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2014/">Bilderarchiv 2014</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2013/">Bilderarchiv 2013</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2012/">Bilderarchiv 2012</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2011/">Bilderarchiv 2011</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2010/">Bilderarchiv 2010</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2009/">Bilderarchiv 2009</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2008/">Bilderarchiv 2008</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2005/">Bilderarchiv 2005</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2004/">Bilderarchiv 2004</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2003/">Bilderarchiv 2003</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2022/">Bilderarchiv 2022</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2016/">Bilderarchiv 2016</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2015/">Bilderarchiv 2015</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2014/">Bilderarchiv 2014</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2013/">Bilderarchiv 2013</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2012/">Bilderarchiv 2012</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2011/">Bilderarchiv 2011</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2010/">Bilderarchiv 2010</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2009/">Bilderarchiv 2009</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2008/">Bilderarchiv 2008</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2005/">Bilderarchiv 2005</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2004/">Bilderarchiv 2004</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2003/">Bilderarchiv 2003</a>
</li>
</ul>
</div>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/category/picture/">Fotoblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/tag-cloud/">Tag Cloud</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/category/spielelog/">Spieleblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/category/kochbuch/">Kochbuch</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/wiki/">Wiki</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/impressum/">Impressum</a>
</li>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/picture/">Fotoblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/tag-cloud/">Tag Cloud</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/spielelog/">Spieleblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/kochbuch/">Kochbuch</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/wiki/">Wiki</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/impressum/">Impressum</a>
</li>
<li class="blog-menu-item blog-menu-calendar">
<button
type="button"
@@ -269,41 +268,33 @@
</div>
</section>
</li>
</ul>
</nav>
<div class="single-post-taxonomy" aria-label="Taxonomy">
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/en/category/aside/">aside</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/en/tag/programmierung/">programmierung</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/en/tag/mac-os-x/">mac-os-x</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/en/tag/sysadmin/">sysadmin</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/aside/">aside</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/programmierung/">programmierung</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/mac-os-x/">mac-os-x</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/sysadmin/">sysadmin</a>
</div>
<article class="single-post" data-template="single-post" data-pagefind-body>
<div class="post"><p><a href="https://www.cmux.dev/de">cmux — The Terminal for Multitasking</a> is a pretty brilliant terminal program for CLI workflows, which are experiencing a resurgence especially through agentic coding. And what I love about it: it has clean persistence of open workspaces where you can have multiple tabs, so I don&#39;t have to keep my work environment open all the time. I&#39;ve always liked having various directories open directly because I switch back and forth between several while programming, and this works much better with CMUX than with anything else.</p>
</div>
<article class="single-post blog-post" data-template="single-post" data-pagefind-body>
<div class="post"><p><a href="https://www.cmux.dev/de">cmux — The Terminal for Multitasking</a> is a pretty brilliant terminal program for CLI workflows, which are experiencing a resurgence especially through agentic coding. And what I love about it: it has clean persistence of open workspaces where you can have multiple tabs, so I don't have to keep my work environment open all the time. I've always liked having various directories open directly because I switch back and forth between several while programming, and this works much better with CMUX than with anything else.</p></div>
</article>
<div class="single-post-backlinks" aria-label="Backlinks">
<span class="single-post-backlinks-label">Linked from</span>
<a class="single-post-taxonomy-bubble single-post-backlink-bubble" href="/en/2026/03/13/ghostty">ghostty</a>
</div>
</main>
</body>
</html>
</html>

View File

@@ -3,19 +3,19 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Content-type: matter-transport/sentient-life-form</title>
<title>Ghostty</title>
<link rel="stylesheet" href="/assets/pico.slate.min.css" />
<link rel="stylesheet" href="/assets/lightbox.min.css" />
<link rel="stylesheet" href="/assets/highlight.min.css" />
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
<link rel="stylesheet" href="/assets/bds.css" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/en/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="/en/atom.xml" />
<link rel="alternate" hreflang="en" href="/en/2026/03/13/ghostty" />
<link rel="alternate" hreflang="en" href="/2026/03/13/ghostty/" />
<script defer src="/assets/highlight.min.js"></script>
<script defer src="/assets/code-enhancements.js"></script>
<script defer src="/assets/d3.layout.cloud.js"></script>
@@ -27,20 +27,19 @@
<link rel="stylesheet" href="/en/pagefind/pagefind-ui.css" />
<script defer src="/en/pagefind/pagefind-ui.js"></script>
</head>
<body>
<main>
<nav class="language-switcher" aria-label="Language">
<a class="language-switcher-badge" href="/en/" data-lang-prefix="" title="de">🇩🇪</a>
<a class="language-switcher-badge" href="/" data-lang-prefix="" title="de">🇩🇪</a>
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="en">🇬🇧</span>
<div class="blog-search-widget" aria-label="Site search">
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="Site search">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
@@ -49,7 +48,7 @@
</svg>
</button>
<div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="Search..."></div>
<div id="blog-search" data-blog-search-root data-search-placeholder="Search..." data-search-no-results="No results found"></div>
</div>
</div>
</nav>
@@ -64,167 +63,167 @@
<h1>Ghostty</h1>
<nav class="blog-menu">
<ul class="blog-menu-list">
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/">Home</a>
<a class="blog-menu-link" href="/">Home</a>
</li>
<li class="blog-menu-item blog-menu-item-with-children">
<span class="blog-menu-link">Bilderarchiv</span>
<div class="blog-menu-submenu">
<ul class="blog-menu-list">
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2022/">Bilderarchiv 2022</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2016/">Bilderarchiv 2016</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2015/">Bilderarchiv 2015</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2014/">Bilderarchiv 2014</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2013/">Bilderarchiv 2013</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2012/">Bilderarchiv 2012</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2011/">Bilderarchiv 2011</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2010/">Bilderarchiv 2010</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2009/">Bilderarchiv 2009</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2008/">Bilderarchiv 2008</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2005/">Bilderarchiv 2005</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2004/">Bilderarchiv 2004</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/bilderarchiv-2003/">Bilderarchiv 2003</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2022/">Bilderarchiv 2022</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2016/">Bilderarchiv 2016</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2015/">Bilderarchiv 2015</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2014/">Bilderarchiv 2014</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2013/">Bilderarchiv 2013</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2012/">Bilderarchiv 2012</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2011/">Bilderarchiv 2011</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2010/">Bilderarchiv 2010</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2009/">Bilderarchiv 2009</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2008/">Bilderarchiv 2008</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2005/">Bilderarchiv 2005</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2004/">Bilderarchiv 2004</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/bilderarchiv-2003/">Bilderarchiv 2003</a>
</li>
</ul>
</div>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/category/picture/">Fotoblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/tag-cloud/">Tag Cloud</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/category/spielelog/">Spieleblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/category/kochbuch/">Kochbuch</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/wiki/">Wiki</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/en/impressum/">Impressum</a>
</li>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/picture/">Fotoblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/tag-cloud/">Tag Cloud</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/spielelog/">Spieleblog</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/category/kochbuch/">Kochbuch</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/wiki/">Wiki</a>
</li>
<li class="blog-menu-item">
<a class="blog-menu-link" href="/impressum/">Impressum</a>
</li>
<li class="blog-menu-item blog-menu-calendar">
<button
type="button"
@@ -269,34 +268,33 @@
</div>
</section>
</li>
</ul>
</nav>
<div class="single-post-taxonomy" aria-label="Taxonomy">
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/en/category/aside/">aside</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/en/tag/programmierung/">programmierung</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/en/tag/sysadmin/">sysadmin</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/en/tag/mac-os-x/">mac-os-x</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/aside/">aside</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/programmierung/">programmierung</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/sysadmin/">sysadmin</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/mac-os-x/">mac-os-x</a>
</div>
<article class="single-post" data-template="single-post" data-pagefind-body>
<div class="post"><p><a href="https://ghostty.org/">Ghostty</a> is the foundation that <a href="/en/2026/03/13/cmux-das-terminal-fur-multitasking">cmux — The Terminal for Multitasking</a> was built on. Generally also a very nice terminal that responds noticeably faster than the standard terminal and already works very well. What I didn&#39;t like was that tabs weren&#39;t automatically reopened in the appropriate directories when the program was closed. I simply have too many persistent sessions that I keep coming back to. cmux just does that better.</p>
</div>
<article class="single-post blog-post" data-template="single-post" data-pagefind-body>
<div class="post"><p><a href="https://ghostty.org/">Ghostty</a> is the foundation that <a href="/en/2026/03/13/cmux-das-terminal-fur-multitasking/">cmux — The Terminal for Multitasking</a> was built on. Generally also a very nice terminal that responds noticeably faster than the standard terminal and already works very well. What I didn't like was that tabs weren't automatically reopened in the appropriate directories when the program was closed. I simply have too many persistent sessions that I keep coming back to. cmux just does that better.</p></div>
</article>
</main>
</body>
</html>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
#!/bin/sh
set -eu
# Run from the RuDS repository root. Optional arguments override the generated
# bDS2 site and bDS2 checkout paths.
source_dir=${1:-"$HOME/Blogs/rfc1437.de/html"}
bds2_dir=${2:-"$(dirname "$PWD")/bDS2"}
target_dir="fixtures/golden-generated-sites/rfc1437-sample"
for relative_path in \
2005/11/13/esmeralda/index.html \
2026/03/13/cmux-das-terminal-fur-multitasking/index.html \
2026/03/13/ghostty/index.html \
atom.xml \
calendar.json \
category/article/index.html \
en/2005/11/13/esmeralda/index.html \
en/2026/03/13/cmux-das-terminal-fur-multitasking/index.html \
en/2026/03/13/ghostty/index.html \
en/index.html \
index.html \
rss.xml \
sitemap.xml
do
cp "$source_dir/$relative_path" "$target_dir/$relative_path"
perl -pi -e 's/[ \t]+$//' "$target_dir/$relative_path"
done
cp "$bds2_dir/priv/preview_assets/assets/bds.css" "$target_dir/assets/bds.css"
cp "$bds2_dir/priv/preview_assets/assets/search-runtime.js" "$target_dir/assets/search-runtime.js"

View File

@@ -10,7 +10,7 @@ render-tagCloud-empty = Keine Tags gefunden.
render-tagCloud-ariaLabel = Tag-Wolke
render-calendar-open = Kalender öffnen
render-calendar-close = Kalender schließen
render-calendar-title = Archivkalender
render-calendar-title = Archiv
render-calendar-loading = Kalender wird geladen …
render-calendar-error = Kalenderdaten konnten nicht geladen werden.
render-taxonomy-ariaLabel = Taxonomie
@@ -33,3 +33,4 @@ render-month-11 = Nov.
render-month-12 = Dezember
render-search-placeholder = Suchen...
render-search-ariaLabel = Seitensuche
render-search-noResults = Keine Ergebnisse gefunden

View File

@@ -33,3 +33,4 @@ render-month-11 = November
render-month-12 = December
render-search-placeholder = Search...
render-search-ariaLabel = Site search
render-search-noResults = No results found

View File

@@ -10,7 +10,7 @@ render-tagCloud-empty = No se encontraron etiquetas.
render-tagCloud-ariaLabel = Nube de etiquetas
render-calendar-open = Abrir calendario
render-calendar-close = Cerrar calendario
render-calendar-title = Calendario de archivo
render-calendar-title = Archivo
render-calendar-loading = Cargando calendario…
render-calendar-error = No se pudieron cargar los datos del calendario.
render-taxonomy-ariaLabel = Taxonomía
@@ -33,3 +33,4 @@ render-month-11 = noviembre
render-month-12 = diciembre
render-search-placeholder = Buscar...
render-search-ariaLabel = Buscar en el sitio
render-search-noResults = No se encontraron resultados

View File

@@ -10,7 +10,7 @@ render-tagCloud-empty = Aucun tag trouvé.
render-tagCloud-ariaLabel = Nuage de tags
render-calendar-open = Ouvrir le calendrier
render-calendar-close = Fermer le calendrier
render-calendar-title = Calendrier des archives
render-calendar-title = Archives
render-calendar-loading = Chargement du calendrier…
render-calendar-error = Impossible de charger les données du calendrier.
render-taxonomy-ariaLabel = Taxonomie
@@ -33,3 +33,4 @@ render-month-11 = novembre
render-month-12 = décembre
render-search-placeholder = Rechercher...
render-search-ariaLabel = Recherche du site
render-search-noResults = Aucun résultat trouvé

View File

@@ -10,7 +10,7 @@ render-tagCloud-empty = Nessun tag trovato.
render-tagCloud-ariaLabel = Nuvola di tag
render-calendar-open = Apri calendario
render-calendar-close = Chiudi calendario
render-calendar-title = Calendario archivio
render-calendar-title = Archivio
render-calendar-loading = Caricamento calendario…
render-calendar-error = Impossibile caricare i dati del calendario.
render-taxonomy-ariaLabel = Tassonomia
@@ -33,3 +33,4 @@ render-month-11 = novembre
render-month-12 = dicembre
render-search-placeholder = Cerca...
render-search-ariaLabel = Ricerca nel sito
render-search-noResults = Nessun risultato trovato

View File

@@ -108,20 +108,30 @@ invariant IncrementalByContentHash {
}
invariant MultiLanguageRoutes {
-- Main language: flat routes (/{yyyy}/{mm}/{dd}/{slug})
-- Main language: unprefixed routes (/{yyyy}/{mm}/{dd}/{slug})
-- Additional languages: prefixed (/{lang}/{yyyy}/{mm}/{dd}/{slug})
-- Each language subtree gets its own feeds and archives
-- Route dates and archive/calendar membership use created_at in local time,
-- matching bDS2.
-- Posts in the page category additionally render at /{slug} in every tree.
}
invariant LanguageVariantSelection {
-- Every language tree renders each post in its own language when a
-- matching variant exists, falling back to the post's original language.
-- This applies uniformly to single pages, list pages (home/pagination,
-- category/tag/date archives), and feeds:
-- matching variant exists. The main tree prefers the main-language
-- translation and otherwise falls back to the original post. An additional
-- tree uses the original post when its source language matches that tree,
-- otherwise its matching translation, falling back to the original post.
-- This applies uniformly to single pages and list pages (home/pagination,
-- category/tag/date archives):
-- Main tree: a post whose language differs from main_language renders
-- its main_language translation when one exists (canonical variant).
-- Additional-language trees: posts resolve to that language's
-- translation; do_not_translate posts are excluded from those trees.
-- Feeds are deliberately narrower: the main feed contains original
-- published records, while an additional-language feed contains only
-- originals or translations whose language matches that tree. A
-- foreign-language HTML fallback is therefore not added to that feed.
}
invariant CanonicalBaseUrlConfigured {
@@ -146,20 +156,26 @@ rule GenerateCoreSectionPages {
requires: core in generation.sections
ensures: FileGenerated("index.html")
ensures: FileGenerated("sitemap.xml")
-- Multi-language sitemap with hreflang alternates
-- Single root sitemap with hreflang alternates; no per-language sitemap
ensures: FileGenerated("rss.xml")
-- RSS 2.0 feed (same output name as the old bDS app; templates link /rss.xml)
-- bDS2-compatible minimal feed over all published snapshots
ensures: FileGenerated("atom.xml")
-- Atom feed
ensures: FileGenerated("calendar.json")
-- Post dates for calendar widget
-- created_at counts for all published snapshots, including list-hidden posts
ensures: FileGenerated("404.html")
-- Not-found page rendered from the not-found template
for p in Posts where status = published and p.categories.contains("page"):
ensures: FileGenerated(format("{slug}/index.html", slug: p.slug))
for lang in generation.blog_languages - {generation.language}:
ensures: FileGenerated(format("{lang}/index.html", lang: lang))
ensures: FileGenerated(format("{lang}/rss.xml", lang: lang))
ensures: FileGenerated(format("{lang}/atom.xml", lang: lang))
ensures: FileGenerated(format("{lang}/404.html", lang: lang))
for p in Posts where status = published and
p.categories.contains("page") and not p.do_not_translate:
ensures: FileGenerated(format("{lang}/{slug}/index.html",
lang: lang, slug: p.slug))
}
-- Single section: one HTML page per published post
@@ -171,7 +187,7 @@ rule GenerateSinglePostPages {
let url = post_canonical_url(p)
ensures: FileGenerated(format("{url}/index.html", url: url))
for lang in generation.blog_languages - {generation.language}:
if p.translations.any(t => t.language.code = lang):
if not p.do_not_translate:
ensures: FileGenerated(format("{lang}/{url}/index.html",
lang: lang, url: url))
}

View File

@@ -151,6 +151,11 @@ value RenderLabels {
-- and macro fallback labels. Month names resolved 1..12 per language.
}
invariant CompleteStarterNavigation {
-- Starter rendering preserves recursive menu children and the archive
-- calendar entry, and provides localized search empty-result text.
}
invariant LabelsUseContentLanguage {
-- RenderLabels and the i18n filter resolve against the content/render
-- language, consistent with i18n.allium's split-localization rule.