diff --git a/examples/official-site/blog.sql b/examples/official-site/blog.sql
index a3789946..5936fdd3 100644
--- a/examples/official-site/blog.sql
+++ b/examples/official-site/blog.sql
@@ -10,6 +10,7 @@ select 'shell' as component,
'Poppins' as font,
'https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js' as javascript,
'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js' as javascript,
+ './rss.sql' as rss,
'/prism-tabler-theme.css' as css;
SELECT 'text' AS component,
diff --git a/examples/official-site/index.sql b/examples/official-site/index.sql
index 7273b43e..b2498fb8 100644
--- a/examples/official-site/index.sql
+++ b/examples/official-site/index.sql
@@ -9,7 +9,8 @@ select 'shell' as component,
'blog' as menu_item,
'documentation' as menu_item,
19 as font_size,
- 'Poppins' as font;
+ 'Poppins' as font,
+ './rss.sql' as rss;
SELECT 'hero' as component,
'SQLPage' as title,
diff --git a/examples/official-site/rss.sql b/examples/official-site/rss.sql
new file mode 100644
index 00000000..3bbe4153
--- /dev/null
+++ b/examples/official-site/rss.sql
@@ -0,0 +1,21 @@
+select 'http_header' as component,
+ 'application/rss+xml' as "Content-Type";
+select 'shell-empty' as component;
+select 'rss' as component,
+ 'SQLPage blog' as title,
+ 'https://sql.ophir.dev/blog.sql' as link,
+ 'latest news about SQLpage' as description,
+ 'en' as language,
+ 'https://sql.ophir.dev/rss.sql' as self_link,
+ 'Technology' as category,
+ '2de3f968-9928-5ec6-9653-6fc6fe382cfd' as guid;
+SELECT title,
+ description,
+ CASE
+ WHEN external_url IS NOT NULL THEN external_url
+ ELSE 'https://sql.ophir.dev/blog.sql?post=' || title
+ END AS link,
+ created_at AS date,
+ false AS explicit
+FROM blog_posts
+ORDER BY created_at DESC;
\ No newline at end of file
diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql
index a3876da0..abeb7a13 100644
--- a/examples/official-site/sqlpage/migrations/01_documentation.sql
+++ b/examples/official-site/sqlpage/migrations/01_documentation.sql
@@ -722,6 +722,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
('link', 'The target of the link in the top navigation bar.', 'URL', TRUE, TRUE),
('css', 'The URL of a CSS file to load and apply to the page.', 'URL', TRUE, TRUE),
('javascript', 'The URL of a Javascript file to load and execute on the page.', 'URL', TRUE, TRUE),
+ ('rss', 'The URL of an RSS feed to display in the top navigation bar. You can use the rss component to generate the field.', 'URL', TRUE, TRUE),
('image', 'The URL of an image to display next to the page title.', 'URL', TRUE, TRUE),
('icon', 'Name of an icon (from tabler-icons.io) to display next to the title in the navigation bar.', 'ICON', TRUE, TRUE),
('menu_item', 'Adds a menu item in the navigation bar at the top of the page. The menu item will have the specified name, and will link to as .sql file of the same name. A dropdown can be generated by passing a json object with a `title` and `submenu` properties.', 'TEXT', TRUE, TRUE),
diff --git a/examples/official-site/sqlpage/migrations/37_rss.sql b/examples/official-site/sqlpage/migrations/37_rss.sql
new file mode 100644
index 00000000..0af9fa69
--- /dev/null
+++ b/examples/official-site/sqlpage/migrations/37_rss.sql
@@ -0,0 +1,273 @@
+-- Documentation for the RSS component
+INSERT INTO component (name, description, icon, introduced_in_version) VALUES (
+ 'rss',
+ 'Produces a data flow in the RSS format.
+Can be used to generate a podcast feed.
+To use this component, you must first return an HTTP header with the "application/rss+xml" content type (see http_header component). Next, you must use the shell-empty component to avoid that SQLPage generates HTML code.',
+ 'rss',
+ '0.20.0'
+);
+
+INSERT INTO parameter (component,name,description,type,top_level,optional) VALUES (
+ 'rss',
+ 'title',
+ 'Defines the title of the channel.',
+ 'TEXT',
+ TRUE,
+ FALSE
+),(
+ 'rss',
+ 'link',
+ 'Defines the hyperlink to the channel.',
+ 'URL',
+ TRUE,
+ FALSE
+),(
+ 'rss',
+ 'description',
+ 'Describes the channel.',
+ 'TEXT',
+ TRUE,
+ FALSE
+),(
+ 'rss',
+ 'language',
+ 'Defines the language of the channel, specified in the ISO 639 format. For example, "en" for English, "fr" for French.',
+ 'TEXT',
+ TRUE,
+ TRUE
+),(
+ 'rss',
+ 'category',
+ 'Defines the category of the channel. The value should be a string representing the category (e.g., "News", "Technology", etc.).',
+ 'TEXT',
+ TRUE,
+ TRUE
+),(
+ 'rss',
+ 'explicit',
+ 'Indicates whether the channel contains explicit content. The value can be either TRUE or FALSE.',
+ 'BOOLEAN',
+ TRUE,
+ TRUE
+),(
+ 'rss',
+ 'image_url',
+ 'Provides a URL linking to the artwork for the channel.',
+ 'URL',
+ TRUE,
+ TRUE
+),(
+ 'rss',
+ 'author',
+ 'Defines the group, person, or people responsible for creating the channel.',
+ 'TEXT',
+ TRUE,
+ TRUE
+),(
+ 'rss',
+ 'copyright',
+ 'Provides the copyright details for the channel.',
+ 'TEXT',
+ TRUE,
+ TRUE
+),(
+ 'rss',
+ 'self_link',
+ 'URL of the RSS feed.',
+ 'URL',
+ TRUE,
+ TRUE
+),(
+ 'rss',
+ 'funding_url',
+ 'Specifies the donation/funding links for the channel. The content of the tag is the recommended string to be used with the link.',
+ 'URL',
+ TRUE,
+ TRUE
+),(
+ 'rss',
+ 'type',
+ 'Specifies the channel as either episodic or serial. The value can be either "episodic" or "serial".',
+ 'TEXT',
+ TRUE,
+ TRUE
+),(
+ 'rss',
+ 'complete',
+ 'Specifies that a channel is complete and will not post any more items in the future.',
+ 'BOOLEAN',
+ TRUE,
+ TRUE
+),(
+ 'rss',
+ 'locked',
+ 'Tells podcast hosting platforms whether they are allowed to import this feed.',
+ 'BOOLEAN',
+ TRUE,
+ TRUE
+),(
+ 'rss',
+ 'guid',
+ 'The globally unique identifier (GUID) for a channel. The value is a UUIDv5.',
+ 'TEXT',
+ TRUE,
+ TRUE
+),(
+ 'rss',
+ 'title',
+ 'Defines the title of the feed item (episode name, blog post title, etc.).',
+ 'TEXT',
+ FALSE,
+ FALSE
+),(
+ 'rss',
+ 'link',
+ 'Defines the hyperlink to the item (blog post URL, etc.).',
+ 'URL',
+ FALSE,
+ FALSE
+),(
+ 'rss',
+ 'description',
+ 'Describes the item',
+ 'TEXT',
+ FALSE,
+ FALSE
+),(
+ 'rss',
+ 'date',
+ 'Indicates when the item was published (RFC-822 date-time).',
+ 'TEXT',
+ FALSE,
+ TRUE
+),(
+ 'rss',
+ 'enclosure_url',
+ 'For podcast episodes, provides a URL linking to the audio/video episode content, in mp3, m4a, m4v, or mp4 format.',
+ 'URL',
+ FALSE,
+ TRUE
+),(
+ 'rss',
+ 'enclosure_length',
+ 'The length in bytes of the audio/video episode content.',
+ 'INTEGER',
+ FALSE,
+ TRUE
+),(
+ 'rss',
+ 'enclosure_type',
+ 'The MIME media type of the audio/video episode content (e.g., "audio/mpeg", "audio/m4a", "video/m4v", "video/mp4").',
+ 'TEXT',
+ FALSE,
+ TRUE
+),(
+ 'rss',
+ 'guid',
+ 'The globally unique identifier (GUID) for an item.',
+ 'TEXT',
+ FALSE,
+ TRUE
+),(
+ 'rss',
+ 'episode',
+ 'The chronological number that is associated with an item.',
+ 'INTEGER',
+ FALSE,
+ TRUE
+),(
+ 'rss',
+ 'season',
+ 'The chronological number associated with an item''s season.',
+ 'INTEGER',
+ FALSE,
+ TRUE
+),(
+ 'rss',
+ 'episode_type',
+ 'Defines the type of content for a specific item. The value can be either "full", "trailer", or "bonus".',
+ 'TEXT',
+ FALSE,
+ TRUE
+),(
+ 'rss',
+ 'block',
+ 'Prevents a specific item from appearing in podcast listening applications.',
+ 'BOOLEAN',
+ FALSE,
+ TRUE
+),(
+ 'rss',
+ 'explicit',
+ 'Indicates whether the item contains explicit content. The value can be either TRUE or FALSE.',
+ 'BOOLEAN',
+ FALSE,
+ TRUE
+),(
+ 'rss',
+ 'image_url',
+ 'Provides a URL linking to the artwork for the item.',
+ 'URL',
+ FALSE,
+ TRUE
+),(
+ 'rss',
+ 'duration',
+ 'The duration of an item in seconds.',
+ 'INTEGER',
+ FALSE,
+ TRUE
+),(
+ 'rss',
+ 'transcript_url',
+ 'A link to a transcript or closed captions file for the item.',
+ 'URL',
+ FALSE,
+ TRUE
+),(
+ 'rss',
+ 'transcript_type',
+ 'The type of the transcript or closed captions file for the item (e.g., "text/plain", "text/html", "text/vtt", "application/json", "application/x-subrip").',
+ 'TEXT',
+ FALSE,
+ TRUE
+);
+
+-- Insert example(s) for the component
+INSERT INTO example (component, description)
+VALUES (
+ 'rss',
+ '
+### An RSS channel about SQLPage latest news.
+
+```sql
+select ''http_header'' as component, ''application/rss+xml'' as content_type;
+select ''shell-empty'' as component;
+select
+ ''rss'' as component,
+ ''SQLPage blog'' as title,
+ ''https://sql.ophir.dev/blog.sql'' as link,
+ ''latest news about SQLpage'' as description,
+ ''en'' as language,
+ ''Technology'' as category,
+ FALSE as explicit,
+ ''https://sql.ophir.dev/favicon.ico'' as image_url,
+ ''Ophir Lojkine'' as author,
+ ''https://github.com/sponsors/lovasoa'' as funding_url,
+ ''episodic'' as type;
+select
+ ''Hello everyone !'' as title,
+ ''https://sql.ophir.dev/blog.sql?post=Come%20see%20me%20build%20twitter%20live%20on%20stage%20in%20Prague'' as link,
+ ''If some of you european SQLPagers are around Prague this december, I will be giving a talk about SQLPage at pgconf.eu on December 14th.'' as description,
+ ''http://127.0.0.1:8080/sqlpage_introduction_video.webm'' as enclosure_url,
+ 123456789 as enclosure_length,
+ ''video/webm'' as enclosure_type,
+ ''2023-12-04'' as date;
+```
+
+Once you have your rss feed ready, you can submit it to podcast directories like
+[Apple Podcasts](https://podcastsconnect.apple.com/my-podcasts),
+[Spotify](https://podcasters.spotify.com/),
+[Google Podcasts](https://podcastsmanager.google.com/)...
+');
\ No newline at end of file
diff --git a/sqlpage/templates/rss.handlebars b/sqlpage/templates/rss.handlebars
new file mode 100644
index 00000000..779e911a
--- /dev/null
+++ b/sqlpage/templates/rss.handlebars
@@ -0,0 +1,42 @@
+
+
+
+ {{title}}
+ {{link}}
+ {{description}}
+ {{~#if language}}{{language}}{{/if}}
+ {{~#if category}}{{sub_category}}{{/if}}
+ {{#if explicit}}true{{else}}false{{/if}}
+ {{~#if image_url}}{{/if}}
+ {{~#if author}}{{author}}{{/if}}
+ {{~#if copyright}}{{copyright}}{{/if}}
+ {{~#if funding_url}}{{funding_text}}{{/if}}
+ {{~#if type}}{{type}}{{/if}}
+ {{~#if complete}}yes{{/if}}
+ {{~#if locked}}yes{{/if}}
+ {{~#if guid}}{{guid}}{{/if}}
+ {{~#if self_link}}{{/if}}
+ {{#each_row}}
+ -
+ {{title}}
+ {{link}}
+ {{description}}
+ {{~#if date}}{{rfc2822_date date}}{{/if}}
+ {{~#if enclosure_url}}{{/if}}
+ {{~#if guid}}{{guid}}{{/if}}
+ {{~#if episode}}{{episode}}{{/if}}
+ {{~#if season}}{{season}}{{/if}}
+ {{~#if episode_type}}{{episode_type}}{{/if}}
+ {{~#if block}}yes{{/if}}
+ {{~#if (not (eq explicit NULL))}}{{#if explicit}}true{{else}}false{{/if}}{{/if}}
+ {{~#if image_url}}{{/if}}
+ {{~#if duration}}{{duration}}{{/if}}
+ {{~#if transcript_url}}{{/if}}
+
+ {{/each_row}}
+
+
diff --git a/sqlpage/templates/shell.handlebars b/sqlpage/templates/shell.handlebars
index 72d0c4b5..788cc12f 100644
--- a/sqlpage/templates/shell.handlebars
+++ b/sqlpage/templates/shell.handlebars
@@ -34,6 +34,9 @@
{{#if refresh}}
{{/if}}
+ {{#if rss}}
+
+ {{/if}}
diff --git a/src/lib.rs b/src/lib.rs
index e20c2195..38845be9 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,6 +7,7 @@ pub mod app_config;
pub mod file_cache;
pub mod filesystem;
pub mod render;
+pub mod template_helpers;
pub mod templates;
pub mod utils;
pub mod webserver;
diff --git a/src/template_helpers.rs b/src/template_helpers.rs
new file mode 100644
index 00000000..6d356382
--- /dev/null
+++ b/src/template_helpers.rs
@@ -0,0 +1,383 @@
+use std::borrow::Cow;
+
+use anyhow::Context as _;
+use handlebars::{
+ handlebars_helper, Context, Handlebars, PathAndJson, RenderError, RenderErrorReason,
+ Renderable, ScopedJson,
+};
+use serde_json::Value as JsonValue;
+
+use crate::utils::static_filename;
+
+/// Simple json to json helper
+type H = fn(&JsonValue) -> JsonValue;
+/// Simple json to json helper with error handling
+type EH = fn(&JsonValue) -> anyhow::Result;
+/// Helper that takes two arguments
+type HH = fn(&JsonValue, &JsonValue) -> JsonValue;
+
+pub fn register_all_helpers(h: &mut Handlebars<'_>) {
+ register_helper(h, "stringify", stringify_helper as H);
+ register_helper(h, "parse_json", parse_json_helper as EH);
+ register_helper(h, "default", default_helper as HH);
+ register_helper(h, "entries", entries_helper as H);
+ // delay helper: store a piece of information in memory that can be output later with flush_delayed
+ h.register_helper("delay", Box::new(delay_helper));
+ h.register_helper("flush_delayed", Box::new(flush_delayed_helper));
+ register_helper(h, "plus", plus_helper as HH);
+ register_helper(h, "plus", minus_helper as HH);
+ h.register_helper("sum", Box::new(sum_helper));
+ register_helper(h, "starts_with", starts_with_helper as HH);
+
+ // to_array: convert a value to a single-element array. If the value is already an array, return it as-is.
+ register_helper(h, "to_array", to_array_helper as H);
+
+ // array_contains: check if an array contains an element. If the first argument is not an array, it is compared to the second argument.
+ handlebars_helper!(array_contains: |array: Json, element: Json| match array {
+ JsonValue::Array(arr) => arr.contains(element),
+ other => other == element
+ });
+ h.register_helper("array_contains", Box::new(array_contains));
+
+ // static_path helper: generate a path to a static file. Replaces sqpage.js by sqlpage..js
+ register_helper(h, "static_path", static_path_helper as EH);
+
+ // icon helper: generate an image with the specified icon
+ h.register_helper("icon_img", Box::new(icon_img_helper));
+ register_helper(h, "markdown", markdown_helper as EH);
+ register_helper(h, "buildinfo", buildinfo_helper as EH);
+ register_helper(h, "typeof", typeof_helper as H);
+ register_helper(h, "rfc2822_date", rfc2822_date_helper as EH);
+}
+
+fn stringify_helper(v: &JsonValue) -> JsonValue {
+ v.to_string().into()
+}
+
+fn parse_json_helper(v: &JsonValue) -> Result {
+ Ok(match v {
+ serde_json::value::Value::String(s) => serde_json::from_str(s)?,
+ other => other.clone(),
+ })
+}
+
+fn default_helper(v: &JsonValue, default: &JsonValue) -> JsonValue {
+ if v.is_null() {
+ default.clone()
+ } else {
+ v.clone()
+ }
+}
+
+fn plus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
+ if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) {
+ (a + b).into()
+ } else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) {
+ (a + b).into()
+ } else {
+ JsonValue::Null
+ }
+}
+
+fn minus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
+ if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) {
+ (a - b).into()
+ } else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) {
+ (a - b).into()
+ } else {
+ JsonValue::Null
+ }
+}
+
+fn starts_with_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
+ if let (Some(a), Some(b)) = (a.as_str(), b.as_str()) {
+ a.starts_with(b)
+ } else if let (Some(arr1), Some(arr2)) = (a.as_array(), b.as_array()) {
+ arr1.starts_with(arr2)
+ } else {
+ false
+ }
+ .into()
+}
+fn entries_helper(v: &JsonValue) -> JsonValue {
+ match v {
+ serde_json::value::Value::Object(map) => map
+ .into_iter()
+ .map(|(k, v)| serde_json::json!({"key": k, "value": v}))
+ .collect(),
+ serde_json::value::Value::Array(values) => values
+ .iter()
+ .enumerate()
+ .map(|(k, v)| serde_json::json!({"key": k, "value": v}))
+ .collect(),
+ _ => vec![],
+ }
+ .into()
+}
+
+fn to_array_helper(v: &JsonValue) -> JsonValue {
+ match v {
+ JsonValue::Array(arr) => arr.clone(),
+ JsonValue::Null => vec![],
+ JsonValue::String(s) if s.starts_with('[') => {
+ if let Ok(JsonValue::Array(r)) = serde_json::from_str(s) {
+ r
+ } else {
+ vec![JsonValue::String(s.clone())]
+ }
+ }
+ other => vec![other.clone()],
+ }
+ .into()
+}
+
+fn static_path_helper(v: &JsonValue) -> anyhow::Result {
+ match v.as_str().with_context(|| "static_path: not a string")? {
+ "sqlpage.js" => Ok(static_filename!("sqlpage.js").into()),
+ "sqlpage.css" => Ok(static_filename!("sqlpage.css").into()),
+ "apexcharts.js" => Ok(static_filename!("apexcharts.js").into()),
+ other => Err(anyhow::anyhow!("unknown static file: {other:?}")),
+ }
+}
+
+fn typeof_helper(v: &JsonValue) -> JsonValue {
+ match v {
+ JsonValue::Null => "null",
+ JsonValue::Bool(_) => "boolean",
+ JsonValue::Number(_) => "number",
+ JsonValue::String(_) => "string",
+ JsonValue::Array(_) => "array",
+ JsonValue::Object(_) => "object",
+ }
+ .into()
+}
+
+fn markdown_helper(x: &JsonValue) -> anyhow::Result {
+ let as_str = match x {
+ JsonValue::String(s) => Cow::Borrowed(s),
+ JsonValue::Array(arr) => Cow::Owned(
+ arr.iter()
+ .map(|v| v.as_str().unwrap_or_default())
+ .collect::>()
+ .join("\n"),
+ ),
+ JsonValue::Null => Cow::Owned(String::new()),
+ other => Cow::Owned(other.to_string()),
+ };
+ markdown::to_html_with_options(&as_str, &markdown::Options::gfm())
+ .map(JsonValue::String)
+ .map_err(|e| anyhow::anyhow!("markdown error: {e}"))
+}
+
+fn buildinfo_helper(x: &JsonValue) -> anyhow::Result {
+ match x {
+ JsonValue::String(s) if s == "CARGO_PKG_NAME" => Ok(env!("CARGO_PKG_NAME").into()),
+ JsonValue::String(s) if s == "CARGO_PKG_VERSION" => Ok(env!("CARGO_PKG_VERSION").into()),
+ other => Err(anyhow::anyhow!("unknown buildinfo key: {other:?}")),
+ }
+}
+
+// rfc2822_date: take an ISO date and convert it to an RFC 2822 date
+fn rfc2822_date_helper(v: &JsonValue) -> anyhow::Result {
+ let date: chrono::DateTime = match v {
+ JsonValue::String(s) => {
+ // we accept both dates with and without time
+ chrono::DateTime::parse_from_rfc3339(s)
+ .or_else(|_| {
+ chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
+ .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc().fixed_offset())
+ })
+ .with_context(|| format!("invalid date: {s}"))?
+ }
+ JsonValue::Number(n) => {
+ chrono::DateTime::from_timestamp(n.as_i64().with_context(|| "not a timestamp")?, 0)
+ .with_context(|| "invalid timestamp")?
+ .into()
+ }
+ other => anyhow::bail!("expected a date, got {other:?}"),
+ };
+ // format: Thu, 01 Jan 1970 00:00:00 +0000
+ Ok(date.format("%a, %d %b %Y %T %z").to_string().into())
+}
+
+fn with_each_block<'a, 'reg, 'rc>(
+ rc: &'a mut handlebars::RenderContext<'reg, 'rc>,
+ mut action: impl FnMut(&mut handlebars::BlockContext<'rc>, bool) -> Result<(), RenderError>,
+) -> Result<(), RenderError> {
+ let mut blks = Vec::new();
+ while let Some(mut top) = rc.block_mut().map(std::mem::take) {
+ rc.pop_block();
+ action(&mut top, rc.block().is_none())?;
+ blks.push(top);
+ }
+ while let Some(blk) = blks.pop() {
+ rc.push_block(blk);
+ }
+ Ok(())
+}
+
+pub(crate) const DELAYED_CONTENTS: &str = "_delayed_contents";
+
+fn delay_helper<'reg, 'rc>(
+ h: &handlebars::Helper<'rc>,
+ r: &'reg Handlebars<'reg>,
+ ctx: &'rc Context,
+ rc: &mut handlebars::RenderContext<'reg, 'rc>,
+ _out: &mut dyn handlebars::Output,
+) -> handlebars::HelperResult {
+ let inner = h
+ .template()
+ .ok_or(RenderErrorReason::BlockContentRequired)?;
+ let mut str_out = handlebars::StringOutput::new();
+ inner.render(r, ctx, rc, &mut str_out)?;
+ let mut delayed_render = str_out.into_string()?;
+ with_each_block(rc, |block, is_last| {
+ if is_last {
+ let old_delayed_render = block
+ .get_local_var(DELAYED_CONTENTS)
+ .and_then(JsonValue::as_str)
+ .unwrap_or_default();
+ delayed_render += old_delayed_render;
+ let contents = JsonValue::String(std::mem::take(&mut delayed_render));
+ block.set_local_var(DELAYED_CONTENTS, contents);
+ }
+ Ok(())
+ })?;
+ Ok(())
+}
+
+fn flush_delayed_helper<'reg, 'rc>(
+ _h: &handlebars::Helper<'rc>,
+ _r: &'reg Handlebars<'reg>,
+ _ctx: &'rc Context,
+ rc: &mut handlebars::RenderContext<'reg, 'rc>,
+ writer: &mut dyn handlebars::Output,
+) -> handlebars::HelperResult {
+ with_each_block(rc, |block_context, _last| {
+ let delayed = block_context
+ .get_local_var(DELAYED_CONTENTS)
+ .and_then(JsonValue::as_str)
+ .filter(|s| !s.is_empty());
+ if let Some(contents) = delayed {
+ writer.write(contents)?;
+ block_context.set_local_var(DELAYED_CONTENTS, JsonValue::Null);
+ }
+ Ok(())
+ })
+}
+
+fn sum_helper<'reg, 'rc>(
+ helper: &handlebars::Helper<'rc>,
+ _r: &'reg Handlebars<'reg>,
+ _ctx: &'rc Context,
+ _rc: &mut handlebars::RenderContext<'reg, 'rc>,
+ writer: &mut dyn handlebars::Output,
+) -> handlebars::HelperResult {
+ let mut sum = 0f64;
+ for v in helper.params() {
+ sum += v
+ .value()
+ .as_f64()
+ .ok_or(RenderErrorReason::InvalidParamType("number"))?;
+ }
+ write!(writer, "{sum}")?;
+ Ok(())
+}
+
+fn icon_img_helper<'reg, 'rc>(
+ helper: &handlebars::Helper<'rc>,
+ _r: &'reg Handlebars<'reg>,
+ _ctx: &'rc Context,
+ _rc: &mut handlebars::RenderContext<'reg, 'rc>,
+ writer: &mut dyn handlebars::Output,
+) -> handlebars::HelperResult {
+ let null = handlebars::JsonValue::Null;
+ let params = [0, 1].map(|i| helper.params().get(i).map_or(&null, PathAndJson::value));
+ let name = match params[0] {
+ JsonValue::String(s) => s,
+ other => {
+ log::debug!("icon_img: {other:?} is not an icon name, not rendering anything");
+ return Ok(());
+ }
+ };
+ let size = params[1].as_u64().unwrap_or(24);
+ write!(
+ writer,
+ "",
+ static_filename!("tabler-icons.svg")
+ )?;
+ Ok(())
+}
+
+trait CanHelp: Send + Sync + 'static {
+ fn call(&self, v: &[PathAndJson]) -> Result;
+}
+
+impl CanHelp for H {
+ fn call(&self, args: &[PathAndJson]) -> Result {
+ match args {
+ [v] => Ok(self(v.value())),
+ _ => Err("expected one argument".to_string()),
+ }
+ }
+}
+
+impl CanHelp for EH {
+ fn call(&self, args: &[PathAndJson]) -> Result {
+ match args {
+ [v] => self(v.value()).map_err(|e| e.to_string()),
+ _ => Err("expected one argument".to_string()),
+ }
+ }
+}
+
+impl CanHelp for HH {
+ fn call(&self, args: &[PathAndJson]) -> Result {
+ match args {
+ [a, b] => Ok(self(a.value(), b.value())),
+ _ => Err("expected two arguments".to_string()),
+ }
+ }
+}
+
+struct JFun {
+ name: &'static str,
+ fun: F,
+}
+impl handlebars::HelperDef for JFun {
+ fn call_inner<'reg: 'rc, 'rc>(
+ &self,
+ helper: &handlebars::Helper<'rc>,
+ _r: &'reg Handlebars<'reg>,
+ _: &'rc Context,
+ _rc: &mut handlebars::RenderContext<'reg, 'rc>,
+ ) -> Result, RenderError> {
+ let result = self
+ .fun
+ .call(helper.params().as_slice())
+ .map_err(|s| RenderErrorReason::Other(format!("{}: {}", self.name, s)))?;
+ Ok(ScopedJson::Derived(result))
+ }
+}
+
+fn register_helper(h: &mut Handlebars, name: &'static str, fun: impl CanHelp) {
+ h.register_helper(name, Box::new(JFun { name, fun }));
+}
+
+#[test]
+fn test_rfc2822_date() {
+ assert_eq!(
+ rfc2822_date_helper(&JsonValue::String("1970-01-02T03:04:05+02:00".into()))
+ .unwrap()
+ .as_str()
+ .unwrap(),
+ "Fri, 02 Jan 1970 03:04:05 +0200"
+ );
+ assert_eq!(
+ rfc2822_date_helper(&JsonValue::String("1970-01-02".into()))
+ .unwrap()
+ .as_str()
+ .unwrap(),
+ "Fri, 02 Jan 1970 00:00:00 +0000"
+ );
+}
diff --git a/src/templates.rs b/src/templates.rs
index 877b9314..3bd74d61 100644
--- a/src/templates.rs
+++ b/src/templates.rs
@@ -1,19 +1,12 @@
use crate::file_cache::AsyncFromStrWithState;
-use crate::utils::static_filename;
+use crate::template_helpers::register_all_helpers;
use crate::{AppState, FileCache, TEMPLATES_DIR};
use async_trait::async_trait;
-use handlebars::{
- handlebars_helper, template::TemplateElement, Context, Handlebars, JsonValue, RenderError,
- Renderable, Template,
-};
-use handlebars::{PathAndJson, RenderErrorReason};
+use handlebars::{template::TemplateElement, Handlebars, Template};
use include_dir::{include_dir, Dir};
-use std::borrow::Cow;
use std::path::PathBuf;
use std::sync::Arc;
-pub(crate) const DELAYED_CONTENTS: &str = "_delayed_contents";
-
pub struct SplitTemplate {
pub before_list: Template,
pub list_content: Template,
@@ -79,234 +72,12 @@ pub struct AllTemplates {
split_templates: FileCache,
}
-fn with_each_block<'a, 'reg, 'rc>(
- rc: &'a mut handlebars::RenderContext<'reg, 'rc>,
- mut action: impl FnMut(&mut handlebars::BlockContext<'rc>, bool) -> Result<(), RenderError>,
-) -> Result<(), RenderError> {
- let mut blks = Vec::new();
- while let Some(mut top) = rc.block_mut().map(std::mem::take) {
- rc.pop_block();
- action(&mut top, rc.block().is_none())?;
- blks.push(top);
- }
- while let Some(blk) = blks.pop() {
- rc.push_block(blk);
- }
- Ok(())
-}
-
-fn delay_helper<'reg, 'rc>(
- h: &handlebars::Helper<'rc>,
- r: &'reg Handlebars<'reg>,
- ctx: &'rc Context,
- rc: &mut handlebars::RenderContext<'reg, 'rc>,
- _out: &mut dyn handlebars::Output,
-) -> handlebars::HelperResult {
- let inner = h
- .template()
- .ok_or(RenderErrorReason::BlockContentRequired)?;
- let mut str_out = handlebars::StringOutput::new();
- inner.render(r, ctx, rc, &mut str_out)?;
- let mut delayed_render = str_out.into_string()?;
- with_each_block(rc, |block, is_last| {
- if is_last {
- let old_delayed_render = block
- .get_local_var(DELAYED_CONTENTS)
- .and_then(JsonValue::as_str)
- .unwrap_or_default();
- delayed_render += old_delayed_render;
- let contents = JsonValue::String(std::mem::take(&mut delayed_render));
- block.set_local_var(DELAYED_CONTENTS, contents);
- }
- Ok(())
- })?;
- Ok(())
-}
-
-fn flush_delayed_helper<'reg, 'rc>(
- _h: &handlebars::Helper<'rc>,
- _r: &'reg Handlebars<'reg>,
- _ctx: &'rc Context,
- rc: &mut handlebars::RenderContext<'reg, 'rc>,
- writer: &mut dyn handlebars::Output,
-) -> handlebars::HelperResult {
- with_each_block(rc, |block_context, _last| {
- let delayed = block_context
- .get_local_var(DELAYED_CONTENTS)
- .and_then(JsonValue::as_str)
- .filter(|s| !s.is_empty());
- if let Some(contents) = delayed {
- writer.write(contents)?;
- block_context.set_local_var(DELAYED_CONTENTS, JsonValue::Null);
- }
- Ok(())
- })
-}
-
-fn sum_helper<'reg, 'rc>(
- helper: &handlebars::Helper<'rc>,
- _r: &'reg Handlebars<'reg>,
- _ctx: &'rc Context,
- _rc: &mut handlebars::RenderContext<'reg, 'rc>,
- writer: &mut dyn handlebars::Output,
-) -> handlebars::HelperResult {
- let mut sum = 0f64;
- for v in helper.params() {
- sum += v
- .value()
- .as_f64()
- .ok_or(RenderErrorReason::InvalidParamType("number"))?;
- }
- write!(writer, "{sum}")?;
- Ok(())
-}
-
-fn icon_img_helper<'reg, 'rc>(
- helper: &handlebars::Helper<'rc>,
- _r: &'reg Handlebars<'reg>,
- _ctx: &'rc Context,
- _rc: &mut handlebars::RenderContext<'reg, 'rc>,
- writer: &mut dyn handlebars::Output,
-) -> handlebars::HelperResult {
- let null = handlebars::JsonValue::Null;
- let params = [0, 1].map(|i| helper.params().get(i).map_or(&null, PathAndJson::value));
- let name = match params[0] {
- JsonValue::String(s) => s,
- other => {
- log::debug!("icon_img: {other:?} is not an icon name, not rendering anything");
- return Ok(());
- }
- };
- let size = params[1].as_u64().unwrap_or(24);
- write!(
- writer,
- "",
- static_filename!("tabler-icons.svg")
- )?;
- Ok(())
-}
-
const STATIC_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/sqlpage/templates");
impl AllTemplates {
pub fn init() -> anyhow::Result {
let mut handlebars = Handlebars::new();
-
- handlebars_helper!(stringify: |v: Json| v.to_string());
- handlebars.register_helper("stringify", Box::new(stringify));
-
- handlebars_helper!(parse_json: |v: Json| match v {
- obj @ serde_json::value::Value::String(s) =>
- serde_json::from_str(s)
- .unwrap_or_else(|_| {
- log::warn!("Failed to parse JSON string: {}", s);
- obj.clone()
- }),
- other => other.clone()
- });
- handlebars.register_helper("parse_json", Box::new(parse_json));
-
- handlebars_helper!(default: |a: Json, b:Json| if a.is_null() {b} else {a}.clone());
- handlebars.register_helper("default", Box::new(default));
-
- handlebars_helper!(entries: |v: Json | match v {
- serde_json::value::Value::Object(map) =>
- map.into_iter()
- .map(|(k, v)| serde_json::json!({"key": k, "value": v}))
- .collect(),
- serde_json::value::Value::Array(values) =>
- values.iter()
- .enumerate()
- .map(|(k, v)| serde_json::json!({"key": k, "value": v}))
- .collect(),
- _ => vec![]
- });
-
- handlebars.register_helper("entries", Box::new(entries));
-
- // delay helper: store a piece of information in memory that can be output later with flush_delayed
- handlebars.register_helper("delay", Box::new(delay_helper));
- handlebars.register_helper("flush_delayed", Box::new(flush_delayed_helper));
-
- handlebars_helper!(plus: |a: Json, b:Json| a.as_i64().unwrap_or_default() + b.as_i64().unwrap_or_default());
- handlebars.register_helper("plus", Box::new(plus));
-
- handlebars_helper!(minus: |a: Json, b:Json| a.as_i64().unwrap_or_default() - b.as_i64().unwrap_or_default());
- handlebars.register_helper("minus", Box::new(minus));
-
- handlebars.register_helper("sum", Box::new(sum_helper));
-
- handlebars_helper!(starts_with: |s: str, prefix:str| s.starts_with(prefix));
- handlebars.register_helper("starts_with", Box::new(starts_with));
-
- // to_array: convert a value to a single-element array. If the value is already an array, return it as-is.
- handlebars_helper!(to_array: |x: Json| match x {
- JsonValue::Array(arr) => arr.clone(),
- JsonValue::Null => vec![],
- JsonValue::String(s) if s.starts_with('[') => {
- if let Ok(JsonValue::Array(r)) = serde_json::from_str(s) {
- r
- } else {
- vec![JsonValue::String(s.clone())]
- }
- }
- other => vec![other.clone()]
- });
- handlebars.register_helper("to_array", Box::new(to_array));
-
- // array_contains: check if an array contains an element. If the first argument is not an array, it is compared to the second argument.
- handlebars_helper!(array_contains: |array: Json, element: Json| match array {
- JsonValue::Array(arr) => arr.contains(element),
- other => other == element
- });
- handlebars.register_helper("array_contains", Box::new(array_contains));
-
- // static_path helper: generate a path to a static file. Replaces sqpage.js by sqlpage..js
- handlebars_helper!(static_path: |x: str| match x {
- "sqlpage.js" => static_filename!("sqlpage.js"),
- "sqlpage.css" => static_filename!("sqlpage.css"),
- "apexcharts.js" => static_filename!("apexcharts.js"),
- unknown => {
- log::error!("Unknown static path: {}", unknown);
- "!!unknown static path!!"
- }
- });
- handlebars.register_helper("static_path", Box::new(static_path));
-
- // icon helper: generate an image with the specified icon
- handlebars.register_helper("icon_img", Box::new(icon_img_helper));
-
- handlebars_helper!(markdown_helper: |x: Json| {
- let as_str = match x {
- JsonValue::String(s) => Cow::Borrowed(s),
- JsonValue::Array(arr) => Cow::Owned(arr.iter().map(|v|v.as_str().unwrap_or_default()).collect::>().join("\n")),
- JsonValue::Null => Cow::Owned(String::new()),
- other => Cow::Owned(other.to_string())
- };
- markdown::to_html_with_options(&as_str, &markdown::Options::gfm())
- .unwrap_or_else(|s|s)
- });
- handlebars.register_helper("markdown", Box::new(markdown_helper));
-
- handlebars_helper!(buildinfo_helper: |x: str|
- match x {
- "CARGO_PKG_NAME" => env!("CARGO_PKG_NAME"),
- "CARGO_PKG_VERSION" => env!("CARGO_PKG_VERSION"),
- _ => "!!unknown buildinfo key!!"
- }
- );
- handlebars.register_helper("buildinfo", Box::new(buildinfo_helper));
-
- handlebars_helper!(typeof_helper: |x: Json| match x {
- JsonValue::Null => "null",
- JsonValue::Bool(_) => "boolean",
- JsonValue::Number(_) => "number",
- JsonValue::String(_) => "string",
- JsonValue::Array(_) => "array",
- JsonValue::Object(_) => "object",
- });
- handlebars.register_helper("typeof", Box::new(typeof_helper));
-
+ register_all_helpers(&mut handlebars);
let mut this = Self {
handlebars,
split_templates: FileCache::new(),