Skip to content

Optional html blocks in markdown #856

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Mar 17, 2025
8 changes: 4 additions & 4 deletions examples/official-site/custom_components.sql
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Each page in SQLPage is composed of a `shell` component,
which contains the page title and the navigation bar,
and a series of normal components that display the data.

The `shell` component is always present unless explicitly skipped via the `?_sqlpage_embed` query parameter.
The `shell` component is always present unless explicitly skipped via the `?_sqlpage_embed` query parameter.
If you don''t call it explicitly, it will be invoked with the default parameters automatically before your first component
invocation that tries to render data on the page.

Expand Down Expand Up @@ -88,7 +88,7 @@ For instance, you can easily create a multi-column layout with the following cod
</div>
```

For custom styling, you can write your own CSS files
For custom styling, you can write your own CSS files
and include them in your page header.
You can use the `css` parameter of the default [`shell`](./documentation.sql?component=shell#component) component,
or create your own custom `shell` component with a `<link>` tag.
Expand Down Expand Up @@ -132,7 +132,7 @@ and SQLPage adds a few more:
- `static_path`: returns the path to one of the static files bundled with SQLPage. Accepts arguments like `sqlpage.js`, `sqlpage.css`, `apexcharts.js`, etc.
- `app_config`: returns the value of a configuration parameter from sqlpage''s configuration file, such as `max_uploaded_file_size`, `site_prefix`, etc.
- `icon_img`: generate an svg icon from a *tabler* icon name
- `markdown`: renders markdown text
- `markdown`: renders markdown text. Accepts an optional 2nd argument `''allow_unsafe''` that will render embedded html blocks: use only on trusted content. See the [Commonmark spec](https://spec.commonmark.org/0.31.2/#html-blocks) for more info.
- `each_row`: iterates over the rows of a query result
- `typeof`: returns the type of a value (`string`, `number`, `boolean`, `object`, `array`, `null`)
- `rfc2822_date`: formats a date as a string in the [RFC 2822](https://tools.ietf.org/html/rfc2822#section-3.3) format, that is, `Thu, 21 Dec 2000 16:01:07 +0200`
Expand Down Expand Up @@ -178,7 +178,7 @@ Some interesting examples are:

- [The `shell` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/shell.handlebars)
- [The `card` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/card.handlebars): simple yet complete example of a component that displays a list of items.
- [The `table` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/table.handlebars): more complex example of a component that uses
- [The `table` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/table.handlebars): more complex example of a component that uses
- the `eq`, `or`, and `sort` handlebars helpers,
- the `../` syntax to access the parent context,
- and the `@key` to work with objects whose keys are not known in advance.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'text', * FROM (VALUES
('unsafe_contents_md','Markdown format with html blocks. Use this only with trusted content. See the html-blocks section of the Commonmark spec for additional info.', 'TEXT', TRUE, TRUE),
('unsafe_contents_md','Markdown format with html blocks. Use this only with trusted content. See the html-blocks section of the Commonmark spec for additional info.', 'TEXT', FALSE, TRUE)
);
8 changes: 8 additions & 0 deletions sqlpage/templates/text.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
{{{~markdown contents_md~}}}
</div>
{{~/if~}}
{{~#if unsafe_contents_md~}}
<div class="remove-bottom-margin {{#if center}}mx-auto{{/if}} {{#if article}}markdown article-text{{/if}}">
{{{~markdown unsafe_contents_md 'allow_unsafe'~}}}
</div>
{{~/if~}}
<p class="{{#if center}}mx-auto{{/if}} {{#if article}}markdown article-text{{/if}}">
{{contents}}
{{~#each_row~}}
Expand All @@ -32,5 +37,8 @@
{{~#if contents_md~}}
{{{markdown contents_md}}}
{{~/if~}}
{{~#if unsafe_contents_md~}}
{{{markdown unsafe_contents_md 'allow_unsafe'}}}
{{~/if~}}
{{~/each_row~}}
</p>
204 changes: 179 additions & 25 deletions src/template_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,26 +251,68 @@ fn typeof_helper(v: &JsonValue) -> JsonValue {
.into()
}

pub trait MarkdownConfig {
fn allow_dangerous_html(&self) -> bool;
fn allow_dangerous_protocol(&self) -> bool;
}

impl MarkdownConfig for AppConfig {
fn allow_dangerous_html(&self) -> bool {
self.markdown_allow_dangerous_html
}

fn allow_dangerous_protocol(&self) -> bool {
self.markdown_allow_dangerous_protocol
}
}

Comment on lines +254 to +268
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is overkill

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to avoid testing with a full AppConfig. Do you have a better alternative in mind?

/// Helper to render markdown with configurable options
#[derive(Default)]
struct MarkdownHelper {
allow_dangerous_html: bool,
allow_dangerous_protocol: bool,
}

impl MarkdownHelper {
fn new(config: &AppConfig) -> Self {
const ALLOW_UNSAFE: &'static str = "allow_unsafe";

fn new(config: &impl MarkdownConfig) -> Self {
Self {
allow_dangerous_html: config.markdown_allow_dangerous_html,
allow_dangerous_protocol: config.markdown_allow_dangerous_protocol,
allow_dangerous_html: config.allow_dangerous_html(),
allow_dangerous_protocol: config.allow_dangerous_protocol(),
}
}

fn calculate_options(&self, args: &[PathAndJson]) -> markdown::Options {
let mut options = self.system_options();

if !options.compile.allow_dangerous_html && args.len() > 1 {
if let Some(arg) = args.get(1) {
if arg.value().as_str() == Some(Self::ALLOW_UNSAFE) {
options.compile.allow_dangerous_html = true;
}
}
}

options
}

fn system_options(&self) -> markdown::Options {
let mut options = markdown::Options::gfm();
options.compile.allow_dangerous_html = self.allow_dangerous_html;
options.compile.allow_dangerous_protocol = self.allow_dangerous_protocol;
options.compile.allow_any_img_src = true;

options
}
}

impl CanHelp for MarkdownHelper {
fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
let options = self.calculate_options(args);
let as_str = match args {
[v] => v.value(),
_ => return Err("expected one argument".to_string()),
[v] | [v, _] => v.value(),
_ => return Err("expected one or two arguments".to_string()),
};
let as_str = match as_str {
JsonValue::String(s) => Cow::Borrowed(s),
Expand All @@ -283,10 +325,7 @@ impl CanHelp for MarkdownHelper {
JsonValue::Null => Cow::Owned(String::new()),
other => Cow::Owned(other.to_string()),
};
let mut options = markdown::Options::gfm();
options.compile.allow_dangerous_html = self.allow_dangerous_html;
options.compile.allow_dangerous_protocol = self.allow_dangerous_protocol;
options.compile.allow_any_img_src = true;

markdown::to_html_with_options(&as_str, &options)
.map(JsonValue::String)
.map_err(|e| e.to_string())
Expand Down Expand Up @@ -543,20 +582,135 @@ fn replace_helper(text: &JsonValue, original: &JsonValue, replacement: &JsonValu
text_str.replace(original_str, replacement_str).into()
}

#[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"
);
#[cfg(test)]
mod tests {
use crate::template_helpers::{rfc2822_date_helper, CanHelp, MarkdownHelper};
use handlebars::{JsonValue, PathAndJson, ScopedJson};
use serde_json::Value;

const CONTENT_KEY: &'static str = "contents_md";

#[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"
);
}

#[test]
fn test_basic_gfm_markdown() {
let helper = MarkdownHelper::default();

let contents = Value::String("# Heading".to_string());
let actual = helper.call(&as_args(&contents)).unwrap();

assert_eq!(Some("<h1>Heading</h1>"), actual.as_str());
}

// Optionally allow potentially unsafe html blocks
// See https://spec.commonmark.org/0.31.2/#html-blocks
mod markdown_html_blocks {

use super::*;

const UNSAFE_MARKUP: &'static str = "<table><tr><td>";
const ESCAPED_UNSAFE_MARKUP: &'static str = "&lt;table&gt;&lt;tr&gt;&lt;td&gt;";

#[test]
fn test_html_blocks_are_not_allowed_by_default() {
let helper = MarkdownHelper::default();
let actual = helper.call(&as_args(&contents())).unwrap();

assert_eq!(Some(ESCAPED_UNSAFE_MARKUP), actual.as_str());
}

#[test]
fn test_html_blocks_are_not_allowed_when_allow_unsafe_is_undefined() {
let helper = MarkdownHelper::default();
let allow_unsafe = Value::Null;
let actual = helper
.call(&as_args_with_unsafe(&contents(), &allow_unsafe))
.unwrap();

assert_eq!(Some(ESCAPED_UNSAFE_MARKUP), actual.as_str());
}

#[test]
fn test_html_blocks_are_not_allowed_when_allow_unsafe_is_false() {
let helper = MarkdownHelper::default();
let allow_unsafe = Value::Bool(false);
let actual = helper
.call(&as_args_with_unsafe(&contents(), &allow_unsafe))
.unwrap();

assert_eq!(Some(ESCAPED_UNSAFE_MARKUP), actual.as_str());
}

#[test]
fn test_html_blocks_are_not_allowed_when_allow_unsafe_option_is_missing() {
let helper = MarkdownHelper::default();
let allow_unsafe = ScopedJson::Missing;
let actual = helper
.call(&[
as_helper_arg(CONTENT_KEY, &contents()),
to_path_and_json(MarkdownHelper::ALLOW_UNSAFE, allow_unsafe),
])
.unwrap();

assert_eq!(Some(ESCAPED_UNSAFE_MARKUP), actual.as_str());
}

#[test]
fn test_html_blocks_are_allowed_when_allow_unsafe_is_true() {
let helper = MarkdownHelper::default();
let allow_unsafe = Value::String(String::from(MarkdownHelper::ALLOW_UNSAFE));
let actual = helper
.call(&as_args_with_unsafe(&contents(), &allow_unsafe))
.unwrap();

assert_eq!(Some(UNSAFE_MARKUP), actual.as_str());
}

fn as_args_with_unsafe<'a>(
contents: &'a Value,
allow_unsafe: &'a Value,
) -> [PathAndJson<'a>; 2] {
[
as_helper_arg(CONTENT_KEY, contents),
as_helper_arg(MarkdownHelper::ALLOW_UNSAFE, allow_unsafe),
]
}

fn contents() -> Value {
Value::String(UNSAFE_MARKUP.to_string())
}
}

fn as_args(contents: &Value) -> [PathAndJson; 1] {
[as_helper_arg(CONTENT_KEY, contents)]
}

fn as_helper_arg<'a>(path: &'a str, value: &'a Value) -> PathAndJson<'a> {
let json_context = as_json_context(path, value);
to_path_and_json(path, json_context)
}

fn to_path_and_json<'a>(path: &'a str, value: ScopedJson<'a>) -> PathAndJson<'a> {
PathAndJson::new(Some(path.to_string()), value)
}

fn as_json_context<'a>(path: &'a str, value: &'a Value) -> ScopedJson<'a> {
ScopedJson::Context(value, vec![path.to_string()])
}
}
2 changes: 2 additions & 0 deletions tests/sql_test_files/it_works_text_markdown.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
select 'text' as component,
'### It works !' AS contents_md;
2 changes: 2 additions & 0 deletions tests/sql_test_files/it_works_text_unsafe_markdown.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
select 'text' as component,
'<span>It works !</span>' AS unsafe_contents_md;