Enforce the supported Liquid subset.
This commit is contained in:
@@ -10,7 +10,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
|||||||
- Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import.
|
- Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import.
|
||||||
- Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
|
- Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
|
||||||
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
||||||
- Post, Liquid template, and Lua script editing with dedicated syntax highlighting and explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync.
|
- Post, Liquid template, and Lua script editing with dedicated syntax highlighting and explicit syntax-check feedback, including publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync.
|
||||||
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, bDS2-compatible NFD slug generation, and FTS5 search.
|
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, bDS2-compatible NFD slug generation, and FTS5 search.
|
||||||
- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace.
|
- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace.
|
||||||
- Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links.
|
- Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links.
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ use crate::model::{DomainEntity, NotificationAction, Template, TemplateKind, Tem
|
|||||||
use crate::util::frontmatter::{TemplateFrontmatter, write_template_file};
|
use crate::util::frontmatter::{TemplateFrontmatter, write_template_file};
|
||||||
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
||||||
|
|
||||||
|
const ALLOWED_LIQUID_TAGS: &[&str] = &[
|
||||||
|
"if", "elsif", "else", "endif", "for", "endfor", "assign", "render",
|
||||||
|
];
|
||||||
|
const ALLOWED_LIQUID_FILTERS: &[&str] = &[
|
||||||
|
"escape",
|
||||||
|
"url_encode",
|
||||||
|
"default",
|
||||||
|
"append",
|
||||||
|
"i18n",
|
||||||
|
"markdown",
|
||||||
|
"slugify",
|
||||||
|
];
|
||||||
|
|
||||||
/// Create a new draft template. Content stored in DB, no file written.
|
/// Create a new draft template. Content stored in DB, no file written.
|
||||||
pub fn create_template(
|
pub fn create_template(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
@@ -136,11 +149,8 @@ pub fn save_template(
|
|||||||
|
|
||||||
/// Validate Liquid template syntax. Returns Ok(()) or a parse error message.
|
/// Validate Liquid template syntax. Returns Ok(()) or a parse error message.
|
||||||
pub fn validate_template(content: &str) -> Result<(), String> {
|
pub fn validate_template(content: &str) -> Result<(), String> {
|
||||||
// Check for basic Liquid syntax correctness:
|
validate_liquid_subset(content)?;
|
||||||
// Matching {% %} tags, matching {{ }}
|
crate::render::validate_liquid_template_syntax(content)
|
||||||
validate_liquid_brackets(content)?;
|
|
||||||
validate_liquid_blocks(content)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a template: write frontmatter+body to file, clear content in DB.
|
/// Publish a template: write frontmatter+body to file, clear content in DB.
|
||||||
@@ -295,112 +305,171 @@ fn template_kind_to_frontmatter(kind: &TemplateKind) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_liquid_brackets(content: &str) -> Result<(), String> {
|
fn validate_liquid_subset(content: &str) -> Result<(), String> {
|
||||||
// Check balanced {{ }} and {% %}
|
let mut cursor = 0;
|
||||||
let mut in_output = false;
|
while let Some((offset, is_tag)) = next_liquid_markup(&content[cursor..]) {
|
||||||
let mut in_tag = false;
|
let markup_start = cursor + offset + 2;
|
||||||
let chars: Vec<char> = content.chars().collect();
|
let close = if is_tag { "%}" } else { "}}" };
|
||||||
let len = chars.len();
|
let Some(close_offset) = find_unquoted(&content[markup_start..], close) else {
|
||||||
let mut i = 0;
|
break;
|
||||||
|
};
|
||||||
|
let markup_end = markup_start + close_offset;
|
||||||
|
let markup = content[markup_start..markup_end]
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches('-')
|
||||||
|
.trim_end_matches('-')
|
||||||
|
.trim();
|
||||||
|
|
||||||
while i < len {
|
let tag = is_tag.then(|| identifier_at_start(markup)).flatten();
|
||||||
if i + 1 < len {
|
if let Some(tag) = tag
|
||||||
let pair = (chars[i], chars[i + 1]);
|
&& !ALLOWED_LIQUID_TAGS.contains(&tag)
|
||||||
match pair {
|
{
|
||||||
('{', '{') if !in_output && !in_tag => {
|
return Err(format!("unsupported tag: {tag}"));
|
||||||
in_output = true;
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
('}', '}') if in_output => {
|
|
||||||
in_output = false;
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
('{', '%') if !in_output && !in_tag => {
|
|
||||||
in_tag = true;
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
('%', '}') if in_tag => {
|
|
||||||
in_tag = false;
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if in_output {
|
validate_liquid_filters(markup)?;
|
||||||
return Err("unclosed {{ output tag".to_string());
|
if let Some(tag @ ("if" | "elsif")) = tag {
|
||||||
}
|
validate_liquid_operators(markup[tag.len()..].trim_start())?;
|
||||||
if in_tag {
|
}
|
||||||
return Err("unclosed {% tag".to_string());
|
cursor = markup_end + close.len();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_liquid_blocks(content: &str) -> Result<(), String> {
|
fn next_liquid_markup(content: &str) -> Option<(usize, bool)> {
|
||||||
// Check that block tags are balanced (if/endif, for/endfor)
|
match (content.find("{%"), content.find("{{")) {
|
||||||
let mut if_depth: i32 = 0;
|
(Some(tag), Some(output)) if tag <= output => Some((tag, true)),
|
||||||
let mut for_depth: i32 = 0;
|
(Some(_), Some(output)) => Some((output, false)),
|
||||||
|
(Some(tag), None) => Some((tag, true)),
|
||||||
|
(None, Some(output)) => Some((output, false)),
|
||||||
|
(None, None) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Simple regex-free scanner for {% tag %} blocks
|
fn find_unquoted(content: &str, needle: &str) -> Option<usize> {
|
||||||
let chars: Vec<char> = content.chars().collect();
|
let bytes = content.as_bytes();
|
||||||
let len = chars.len();
|
let needle = needle.as_bytes();
|
||||||
let mut i = 0;
|
let mut quote = None;
|
||||||
|
let mut escaped = false;
|
||||||
while i < len {
|
let mut index = 0;
|
||||||
if i + 1 < len && chars[i] == '{' && chars[i + 1] == '%' {
|
while index + needle.len() <= bytes.len() {
|
||||||
// Find closing %}
|
let byte = bytes[index];
|
||||||
let start = i + 2;
|
if let Some(active_quote) = quote {
|
||||||
if let Some(end_offset) = content[start..].find("%}") {
|
if escaped {
|
||||||
let tag_content = content[start..start + end_offset].trim();
|
escaped = false;
|
||||||
let tag_content = tag_content
|
} else if byte == b'\\' {
|
||||||
.trim_start_matches('-')
|
escaped = true;
|
||||||
.trim_end_matches('-')
|
} else if byte == active_quote {
|
||||||
.trim();
|
quote = None;
|
||||||
let first_word = tag_content.split_whitespace().next().unwrap_or("");
|
|
||||||
|
|
||||||
match first_word {
|
|
||||||
"if" => if_depth += 1,
|
|
||||||
"endif" => {
|
|
||||||
if_depth -= 1;
|
|
||||||
if if_depth < 0 {
|
|
||||||
return Err(
|
|
||||||
"unexpected {% endif %} without matching {% if %}".to_string()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"for" => for_depth += 1,
|
|
||||||
"endfor" => {
|
|
||||||
for_depth -= 1;
|
|
||||||
if for_depth < 0 {
|
|
||||||
return Err(
|
|
||||||
"unexpected {% endfor %} without matching {% for %}".to_string()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
i = start + end_offset + 2;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
} else if matches!(byte, b'\'' | b'"') {
|
||||||
|
quote = Some(byte);
|
||||||
|
} else if &bytes[index..index + needle.len()] == needle {
|
||||||
|
return Some(index);
|
||||||
}
|
}
|
||||||
i += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
if if_depth > 0 {
|
fn identifier_at_start(content: &str) -> Option<&str> {
|
||||||
return Err(format!("{if_depth} unclosed {{% if %}} block(s)"));
|
let end = content
|
||||||
}
|
.find(|character: char| !is_liquid_identifier(character))
|
||||||
if for_depth > 0 {
|
.unwrap_or(content.len());
|
||||||
return Err(format!("{for_depth} unclosed {{% for %}} block(s)"));
|
(end > 0).then(|| &content[..end])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_liquid_identifier(character: char) -> bool {
|
||||||
|
character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_liquid_filters(markup: &str) -> Result<(), String> {
|
||||||
|
for pipe in unquoted_byte_positions(markup, b'|') {
|
||||||
|
let after_pipe = markup[pipe + 1..].trim_start();
|
||||||
|
if let Some(filter) = identifier_at_start(after_pipe)
|
||||||
|
&& !ALLOWED_LIQUID_FILTERS.contains(&filter)
|
||||||
|
{
|
||||||
|
return Err(format!("unsupported filter: {filter}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_liquid_operators(markup: &str) -> Result<(), String> {
|
||||||
|
let bytes = markup.as_bytes();
|
||||||
|
let mut quote = None;
|
||||||
|
let mut escaped = false;
|
||||||
|
let mut index = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
let byte = bytes[index];
|
||||||
|
if let Some(active_quote) = quote {
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if byte == b'\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if byte == active_quote {
|
||||||
|
quote = None;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if matches!(byte, b'\'' | b'"') {
|
||||||
|
quote = Some(byte);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let operator = match (byte, bytes.get(index + 1).copied()) {
|
||||||
|
(b'!', Some(b'=')) => Some("!="),
|
||||||
|
(b'>', Some(b'=')) => Some(">="),
|
||||||
|
(b'<', Some(b'=')) => Some("<="),
|
||||||
|
(b'<', _) => Some("<"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(operator) = operator {
|
||||||
|
return Err(format!("unsupported operator: {operator}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes[index..].starts_with(b"contains")
|
||||||
|
&& is_word_boundary(bytes.get(index.wrapping_sub(1)).copied())
|
||||||
|
&& is_word_boundary(bytes.get(index + "contains".len()).copied())
|
||||||
|
&& !markup[..index].trim_end().is_empty()
|
||||||
|
&& !markup[index + "contains".len()..].trim_start().is_empty()
|
||||||
|
&& !markup[..index].trim_end().ends_with('.')
|
||||||
|
{
|
||||||
|
return Err("unsupported operator: contains".to_string());
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_word_boundary(byte: Option<u8>) -> bool {
|
||||||
|
byte.is_none_or(|byte| !byte.is_ascii_alphanumeric() && !matches!(byte, b'_' | b'-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unquoted_byte_positions(content: &str, target: u8) -> Vec<usize> {
|
||||||
|
let mut positions = Vec::new();
|
||||||
|
let mut quote = None;
|
||||||
|
let mut escaped = false;
|
||||||
|
for (index, byte) in content.bytes().enumerate() {
|
||||||
|
if let Some(active_quote) = quote {
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if byte == b'\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if byte == active_quote {
|
||||||
|
quote = None;
|
||||||
|
}
|
||||||
|
} else if matches!(byte, b'\'' | b'"') {
|
||||||
|
quote = Some(byte);
|
||||||
|
} else if byte == target {
|
||||||
|
positions.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
positions
|
||||||
|
}
|
||||||
|
|
||||||
fn count_posts_using_template(conn: &Connection, slug: &str) -> EngineResult<usize> {
|
fn count_posts_using_template(conn: &Connection, slug: &str) -> EngineResult<usize> {
|
||||||
let count: i64 = conn.with(|c| {
|
let count: i64 = conn.with(|c| {
|
||||||
posts::table
|
posts::table
|
||||||
@@ -584,6 +653,141 @@ mod tests {
|
|||||||
assert!(validate_template("{% for p in posts %}{{ p.title }}{% endfor %}").is_ok());
|
assert!(validate_template("{% for p in posts %}{{ p.title }}{% endfor %}").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_unsupported_liquid_tags() {
|
||||||
|
for tag in [
|
||||||
|
"unless",
|
||||||
|
"case",
|
||||||
|
"capture",
|
||||||
|
"tablerow",
|
||||||
|
"cycle",
|
||||||
|
"increment",
|
||||||
|
] {
|
||||||
|
let content = format!("{{% {tag} foo %}}bar{{% end{tag} %}}");
|
||||||
|
assert_eq!(
|
||||||
|
validate_template(&content),
|
||||||
|
Err(format!("unsupported tag: {tag}"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_unsupported_liquid_filters() {
|
||||||
|
for filter in ["upcase", "downcase", "date", "truncate", "split", "reverse"] {
|
||||||
|
let content = format!("{{{{ title | {filter} }}}}");
|
||||||
|
assert_eq!(
|
||||||
|
validate_template(&content),
|
||||||
|
Err(format!("unsupported filter: {filter}"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_unsupported_liquid_operators() {
|
||||||
|
for operator in ["!=", "<", ">=", "<=", "contains"] {
|
||||||
|
let content = format!("{{% if title {operator} other %}}yes{{% endif %}}");
|
||||||
|
assert_eq!(
|
||||||
|
validate_template(&content),
|
||||||
|
Err(format!("unsupported operator: {operator}"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_allows_the_complete_liquid_subset() {
|
||||||
|
for filter in [
|
||||||
|
"escape",
|
||||||
|
"url_encode",
|
||||||
|
"default: fallback",
|
||||||
|
"append: suffix",
|
||||||
|
"i18n: language",
|
||||||
|
"markdown: post.id, posts, post_paths, media_paths, language, prefix",
|
||||||
|
"slugify",
|
||||||
|
] {
|
||||||
|
let result = validate_template(&format!("{{{{ title | {filter} }}}}"));
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"supported filter {filter} was rejected: {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for content in [
|
||||||
|
"{% if title == other %}yes{% elsif total > 0 %}more{% else %}no{% endif %}",
|
||||||
|
"{% if published %}yes{% endif %}",
|
||||||
|
"{% if a == b and c > d %}yes{% endif %}",
|
||||||
|
"{% if a == b or c == blank %}yes{% endif %}",
|
||||||
|
"{% assign href = '/posts/' | append: post.slug %}",
|
||||||
|
"{% render 'partials/card', post: post %}",
|
||||||
|
"{%- for post in posts -%}{{- post.title | escape -}}{%- endfor -%}",
|
||||||
|
"{% if values.size > 0 and map[key] %}yes{% endif %}",
|
||||||
|
] {
|
||||||
|
let result = validate_template(content);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"supported syntax was rejected: {content}: {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundled_starter_templates_conform_to_the_published_liquid_subset() {
|
||||||
|
for (name, content) in [
|
||||||
|
(
|
||||||
|
"single-post",
|
||||||
|
include_str!("../../../../assets/starter-templates/single-post.liquid"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"post-list",
|
||||||
|
include_str!("../../../../assets/starter-templates/post-list.liquid"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"not-found",
|
||||||
|
include_str!("../../../../assets/starter-templates/not-found.liquid"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"head",
|
||||||
|
include_str!("../../../../assets/starter-templates/partials/head.liquid"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language-switcher",
|
||||||
|
include_str!(
|
||||||
|
"../../../../assets/starter-templates/partials/language-switcher.liquid"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"menu-items",
|
||||||
|
include_str!("../../../../assets/starter-templates/partials/menu-items.liquid"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"menu",
|
||||||
|
include_str!("../../../../assets/starter-templates/partials/menu.liquid"),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let result = validate_template(content);
|
||||||
|
assert!(result.is_ok(), "starter template {name}: {result:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publish_rejects_unsupported_filter_and_leaves_template_draft() {
|
||||||
|
let (db, dir) = setup();
|
||||||
|
let template = create_template(
|
||||||
|
db.conn(),
|
||||||
|
"p1",
|
||||||
|
"Strict",
|
||||||
|
TemplateKind::Post,
|
||||||
|
"{{ title | upcase }}",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = publish_template(db.conn(), dir.path(), &template.id).unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("unsupported filter: upcase"));
|
||||||
|
let reloaded = qt::get_template_by_id(db.conn(), &template.id).unwrap();
|
||||||
|
assert_eq!(reloaded.status, TemplateStatus::Draft);
|
||||||
|
assert_eq!(reloaded.content.as_deref(), Some("{{ title | upcase }}"));
|
||||||
|
assert!(!dir.path().join("templates/strict.liquid").exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_unclosed_output() {
|
fn validate_unclosed_output() {
|
||||||
assert!(validate_template("<div>{{ title </div>").is_err());
|
assert!(validate_template("<div>{{ title </div>").is_err());
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ pub use generation::{
|
|||||||
write_generated_bytes, write_generated_file,
|
write_generated_bytes, write_generated_file,
|
||||||
};
|
};
|
||||||
pub use markdown::render_markdown_to_html;
|
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 use page_renderer::{RenderError, render_liquid_template};
|
||||||
|
pub(crate) use page_renderer::{render_liquid_template_with_host, validate_liquid_template_syntax};
|
||||||
pub(crate) use routes::{PostLanguageVariant, blog_page_title, select_post_language_variant};
|
pub(crate) use routes::{PostLanguageVariant, blog_page_title, select_post_language_variant};
|
||||||
pub use routes::{
|
pub use routes::{
|
||||||
RenderedPage, build_canonical_post_path, render_starter_list_page,
|
RenderedPage, build_canonical_post_path, render_starter_list_page,
|
||||||
|
|||||||
@@ -56,10 +56,7 @@ pub(crate) fn render_liquid_template_with_host<T: Serialize>(
|
|||||||
compiled_partials.add(format!("{name}.liquid"), content.clone());
|
compiled_partials.add(format!("{name}.liquid"), content.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let parser = ParserBuilder::with_stdlib()
|
let parser = liquid_parser_builder(host)
|
||||||
.filter(I18n)
|
|
||||||
.filter(Markdown { host })
|
|
||||||
.filter(Slugify)
|
|
||||||
.partials(compiled_partials)
|
.partials(compiled_partials)
|
||||||
.build()?;
|
.build()?;
|
||||||
let template = parser.parse(template_source)?;
|
let template = parser.parse(template_source)?;
|
||||||
@@ -67,6 +64,23 @@ pub(crate) fn render_liquid_template_with_host<T: Serialize>(
|
|||||||
Ok(template.render(&globals)?)
|
Ok(template.render(&globals)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_liquid_template_syntax(template_source: &str) -> Result<(), String> {
|
||||||
|
let parser = liquid_parser_builder(Arc::new(UnavailableHost))
|
||||||
|
.build()
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
parser
|
||||||
|
.parse(template_source)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liquid_parser_builder(host: Arc<dyn HostApi>) -> ParserBuilder {
|
||||||
|
ParserBuilder::with_stdlib()
|
||||||
|
.filter(I18n)
|
||||||
|
.filter(Markdown { host })
|
||||||
|
.filter(Slugify)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, ParseFilter, FilterReflection)]
|
#[derive(Clone, ParseFilter, FilterReflection)]
|
||||||
#[filter(
|
#[filter(
|
||||||
name = "slugify",
|
name = "slugify",
|
||||||
|
|||||||
@@ -2278,6 +2278,7 @@ mod tests {
|
|||||||
post = bds.posts.get(post.id).title,
|
post = bds.posts.get(post.id).title,
|
||||||
script = script.kind,
|
script = script.kind,
|
||||||
template = template.kind,
|
template = template.kind,
|
||||||
|
template_validation = bds.templates.validate("{{ title | upcase }}"),
|
||||||
tag = tag.name,
|
tag = tag.name,
|
||||||
media = media.title,
|
media = media.title,
|
||||||
category = metadata.categories[1],
|
category = metadata.categories[1],
|
||||||
@@ -2304,6 +2305,11 @@ mod tests {
|
|||||||
assert_eq!(result.value["post"], "Lua post");
|
assert_eq!(result.value["post"], "Lua post");
|
||||||
assert_eq!(result.value["script"], "utility");
|
assert_eq!(result.value["script"], "utility");
|
||||||
assert_eq!(result.value["template"], "post");
|
assert_eq!(result.value["template"], "post");
|
||||||
|
assert_eq!(result.value["template_validation"]["valid"], false);
|
||||||
|
assert_eq!(
|
||||||
|
result.value["template_validation"]["errors"][0],
|
||||||
|
"unsupported filter: upcase"
|
||||||
|
);
|
||||||
assert_eq!(result.value["tag"], "lua");
|
assert_eq!(result.value["tag"], "lua");
|
||||||
assert_eq!(result.value["media"], "Close");
|
assert_eq!(result.value["media"], "Close");
|
||||||
assert!(result.value["foreign"].is_null());
|
assert!(result.value["foreign"].is_null());
|
||||||
|
|||||||
@@ -489,6 +489,31 @@ fn every_write_tool_is_inert_then_approves_or_rejects_through_shared_engines() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn propose_template_rejects_unsupported_liquid_without_creating_a_proposal() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let context = fixture.context();
|
||||||
|
|
||||||
|
let error = context
|
||||||
|
.call_tool(
|
||||||
|
"propose_template",
|
||||||
|
json!({
|
||||||
|
"title": "Unsafe template",
|
||||||
|
"kind": "partial",
|
||||||
|
"content": "{{ title | upcase }}"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("unsupported filter: upcase"));
|
||||||
|
let db = fixture.database();
|
||||||
|
assert!(
|
||||||
|
mcp::list_proposals(db.conn(), &fixture.project_id)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn expiry_invalid_ids_unavailable_projects_and_concurrent_acceptance_are_safe() {
|
fn expiry_invalid_ids_unavailable_projects_and_concurrent_acceptance_are_safe() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user