feat: first take at M4

This commit is contained in:
2026-04-10 15:32:05 +02:00
parent ee961f1b02
commit 3ca80b00d0
27 changed files with 3923 additions and 21 deletions

View File

@@ -0,0 +1,335 @@
use std::collections::HashMap;
use liquid::ParserBuilder;
use liquid::partials::{EagerCompiler, InMemorySource};
use liquid_core::{
Display_filter, Expression, Filter, FilterParameters, FilterReflection,
FromFilterParameters, ParseFilter, Runtime, Value, ValueView,
};
use serde::Serialize;
use thiserror::Error;
use crate::i18n::translate_render;
use crate::render::render_markdown_to_html;
#[derive(Debug, Error)]
pub enum RenderError {
#[error("liquid error: {0}")]
Liquid(#[from] liquid::Error),
}
#[derive(Debug, Clone, Default)]
struct HtmlRewriteContext {
canonical_post_path_by_slug: HashMap<String, String>,
canonical_media_path_by_source_path: HashMap<String, String>,
}
pub fn render_liquid_template<T: Serialize>(
template_source: &str,
partials: &HashMap<String, String>,
context: &T,
) -> Result<String, RenderError> {
let mut compiled_partials: EagerCompiler<InMemorySource> = EagerCompiler::empty();
for (name, content) in partials {
compiled_partials.add(format!("{name}.liquid"), content.clone());
}
let parser = ParserBuilder::with_stdlib()
.filter(I18n)
.filter(Markdown)
.partials(compiled_partials)
.build()?;
let template = parser.parse(template_source)?;
let globals = liquid::to_object(context)?;
Ok(template.render(&globals)?)
}
#[derive(Debug, FilterParameters)]
struct I18nArgs {
#[parameter(description = "Render language", arg_type = "str")]
language: Expression,
}
#[derive(Clone, ParseFilter, FilterReflection)]
#[filter(
name = "i18n",
description = "Translate a render key for a content language.",
parameters(I18nArgs),
parsed(I18nFilter)
)]
struct I18n;
#[derive(Debug, FromFilterParameters, Display_filter)]
#[name = "i18n"]
struct I18nFilter {
#[parameters]
args: I18nArgs,
}
impl Filter for I18nFilter {
fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> liquid_core::Result<Value> {
let args = self.args.evaluate(runtime)?;
let key = input.to_kstr();
let language = args.language.to_kstr();
Ok(Value::scalar(translate_render(language.as_str(), key.as_str())))
}
}
#[derive(Debug, FilterParameters)]
struct MarkdownArgs {
#[parameter(description = "Post id", arg_type = "str")]
post_id: Option<Expression>,
#[parameter(description = "Post data by id", arg_type = "any")]
post_data_json_by_id: Option<Expression>,
#[parameter(description = "Canonical post path map", arg_type = "any")]
canonical_post_path_by_slug: Option<Expression>,
#[parameter(description = "Canonical media path map", arg_type = "any")]
canonical_media_path_by_source_path: Option<Expression>,
#[parameter(description = "Render language", arg_type = "str")]
language: Option<Expression>,
#[parameter(description = "Language prefix", arg_type = "str")]
language_prefix: Option<Expression>,
}
#[derive(Clone, ParseFilter, FilterReflection)]
#[filter(
name = "markdown",
description = "Render markdown to HTML and rewrite preview URLs.",
parameters(MarkdownArgs),
parsed(MarkdownFilter)
)]
struct Markdown;
#[derive(Debug, FromFilterParameters, Display_filter)]
#[name = "markdown"]
struct MarkdownFilter {
#[parameters]
args: MarkdownArgs,
}
impl Filter for MarkdownFilter {
fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> liquid_core::Result<Value> {
let args = self.args.evaluate(runtime)?;
let markdown = input.to_kstr();
let rewrite_context = HtmlRewriteContext {
canonical_post_path_by_slug: args
.canonical_post_path_by_slug
.as_ref()
.map(value_to_string_map)
.unwrap_or_default(),
canonical_media_path_by_source_path: args
.canonical_media_path_by_source_path
.as_ref()
.map(value_to_string_map)
.unwrap_or_default(),
};
let rendered = render_markdown_to_html(markdown.as_str());
Ok(Value::scalar(rewrite_rendered_html_urls(&rendered, &rewrite_context)))
}
}
fn value_to_string_map(value: &impl ValueView) -> HashMap<String, String> {
value
.as_object()
.map(|object| {
object
.iter()
.map(|(key, value)| (key.to_string(), value.to_kstr().into_owned().to_string()))
.collect()
})
.unwrap_or_default()
}
pub(crate) fn rewrite_rendered_html_urls(html: &str, rewrite_context: &impl RewriteContextView) -> String {
let rewritten = rewrite_attribute_urls(html, "href", |href| normalize_preview_href(href, rewrite_context));
rewrite_attribute_urls(&rewritten, "src", |src| normalize_preview_src(src, rewrite_context))
}
pub(crate) trait RewriteContextView {
fn canonical_post_path_by_slug(&self) -> &HashMap<String, String>;
fn canonical_media_path_by_source_path(&self) -> &HashMap<String, String>;
}
impl RewriteContextView for HtmlRewriteContext {
fn canonical_post_path_by_slug(&self) -> &HashMap<String, String> {
&self.canonical_post_path_by_slug
}
fn canonical_media_path_by_source_path(&self) -> &HashMap<String, String> {
&self.canonical_media_path_by_source_path
}
}
fn rewrite_attribute_urls(
html: &str,
attribute: &str,
normalize: impl Fn(&str) -> String,
) -> String {
let mut result = String::with_capacity(html.len());
let mut cursor = 0;
while let Some(offset) = html[cursor..].find(attribute) {
let attr_start = cursor + offset;
result.push_str(&html[cursor..attr_start]);
result.push_str(attribute);
let after_attr = attr_start + attribute.len();
if !html[after_attr..].starts_with('=') {
cursor = after_attr;
continue;
}
result.push('=');
let quote_index = after_attr + 1;
let Some(quote) = html[quote_index..].chars().next() else {
cursor = after_attr + 1;
continue;
};
if quote != '\'' && quote != '"' {
cursor = after_attr + 1;
continue;
}
result.push(quote);
let value_start = quote_index + quote.len_utf8();
let Some(value_end_rel) = html[value_start..].find(quote) else {
result.push_str(&html[value_start..]);
return result;
};
let value_end = value_start + value_end_rel;
result.push_str(&normalize(&html[value_start..value_end]));
result.push(quote);
cursor = value_end + quote.len_utf8();
}
result.push_str(&html[cursor..]);
result
}
fn normalize_preview_href(raw_href: &str, rewrite_context: &impl RewriteContextView) -> String {
if raw_href.is_empty() || is_external_or_special_url(raw_href) {
return raw_href.to_string();
}
let (path_part, suffix) = split_path_suffix(raw_href.trim());
if let Some(normalized) = normalize_day_route(path_part) {
return format!("{normalized}{suffix}");
}
if let Some(slug) = extract_post_slug(path_part) {
let canonical = rewrite_context
.canonical_post_path_by_slug()
.get(&slug)
.cloned()
.unwrap_or_else(|| format!("/posts/{slug}"));
return format!("{canonical}{suffix}");
}
if let Some(media_source_key) = extract_media_source_key(path_part) {
let canonical = rewrite_context
.canonical_media_path_by_source_path()
.get(&media_source_key)
.cloned()
.unwrap_or_else(|| format!("/{media_source_key}"));
return format!("{canonical}{suffix}");
}
raw_href.to_string()
}
fn normalize_preview_src(raw_src: &str, rewrite_context: &impl RewriteContextView) -> String {
if raw_src.is_empty() || is_external_or_special_url(raw_src) {
return raw_src.to_string();
}
let (path_part, suffix) = split_path_suffix(raw_src.trim());
if let Some(media_source_key) = extract_media_source_key(path_part) {
let canonical = rewrite_context
.canonical_media_path_by_source_path()
.get(&media_source_key)
.cloned()
.unwrap_or_else(|| format!("/{media_source_key}"));
return format!("{canonical}{suffix}");
}
raw_src.to_string()
}
fn is_external_or_special_url(value: &str) -> bool {
let normalized = value.trim();
if normalized.is_empty() {
return false;
}
if normalized.starts_with('#') || normalized.starts_with("//") {
return true;
}
let mut seen_alpha = false;
for ch in normalized.chars() {
if ch == ':' {
return seen_alpha;
}
if ch.is_ascii_alphanumeric() || matches!(ch, '+' | '.' | '-') {
seen_alpha = true;
continue;
}
break;
}
false
}
fn split_path_suffix(value: &str) -> (&str, &str) {
let split_index = value.find(['?', '#']).unwrap_or(value.len());
(&value[..split_index], &value[split_index..])
}
fn normalize_day_route(path: &str) -> Option<String> {
let segments: Vec<_> = path.trim_start_matches('/').split('/').collect();
if segments.len() != 4 {
return None;
}
let [year, month, day, slug] = segments.as_slice() else {
return None;
};
if year.len() != 4 || !year.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
let month = month.parse::<u32>().ok()?;
let day = day.parse::<u32>().ok()?;
if slug.is_empty() {
return None;
}
Some(format!("/{year}/{month:02}/{day:02}/{slug}"))
}
fn extract_post_slug(path: &str) -> Option<String> {
let trimmed = path.trim_start_matches('/');
let segments: Vec<_> = trimmed.split('/').collect();
match segments.as_slice() {
["post" | "posts", slug] => Some(trim_html_suffix(slug)),
["post" | "posts", year, month, slug] if year.len() == 4 && month.chars().all(|ch| ch.is_ascii_digit()) => {
Some(trim_html_suffix(slug))
}
_ => None,
}
}
fn extract_media_source_key(path: &str) -> Option<String> {
let trimmed = path.trim_start_matches('/');
let segments: Vec<_> = trimmed.split('/').collect();
match segments.as_slice() {
["media", year, month, filename] if year.len() == 4 && month.len() == 2 => {
Some(format!("media/{year}/{month}/{}", filename.to_lowercase()))
}
_ => None,
}
}
fn trim_html_suffix(value: &str) -> String {
value
.trim_end_matches(".html")
.trim_end_matches(".htm")
.to_string()
}