mde / ejs

Embedded JavaScript templates -- http://ejs.co
Apache License 2.0
7.75k stars 842 forks source link

block/template/extend support for ejs #252

Open huxia opened 7 years ago

huxia commented 7 years ago

Currently ejs doesn't have any block/template/extend features.

existing solutions:

https://github.com/seqs/ejs-blocks

neat javascript grammar, however it only support raw strings as block content.

https://github.com/tj/ejs/pull/142 https://github.com/User4martin/ejs/blob/plugin-snippets/docs/plugin-snippet.md

they invents several preprocessor directives like <%block header%>, <%/block header%> <%+ template%>, <%* snippet name %>, <%* /snippet %>, thus not very easy to learn, this is against ejs's design goals.

this approach:

page implementation (home.ejs):

<!-- define block contents by functions, it should be able to access the same locals data & context -->
<% var head = () => { %>
  <%- include('./include.css') %>
  <title>Hello EJS Template</title>
<% } -%>
<% var body = () => { %>
  <div>
    you have an message: <%= message.toLowerCase() %>
  </div>
<% } -%>

<!-- a single "include" finally, and its contents are passed by locals -->
<%- include('./layout', {body, head}) %>

template/layout declaration (layout.ejs):

<!-- NOTE: template/layout can be nested -->
<html>
    <head>
        <% if (!head) { %>
            <title>default title</title>
        <% } else { %>
            <!-- NOTE: this is the only one thing changed for ejs users, ejs "include" function now accept function as its first argument -->
            <%- include(head) %>
        <% } %>
    </head>
    <body>
        <h1>This is a layout</h1>
        <% if (!body) { %>
            <small>default content</small>
        <% } else { %>
            <!-- same above -->
            <%- include(body) %>
        <% } %>
    </body>
</html>

advantages

huxia commented 7 years ago

PR, any suggestions are welcome: https://github.com/mde/ejs/pull/251

rossrossp commented 7 years ago

Is anything happening with this?

RyanZim commented 7 years ago

See https://github.com/mde/ejs/pull/251 for discussion; I'm not going to merge anything on this front without permission from the other maintainers.

huxia commented 6 years ago

Compare to the original solution (to invent "block"/"blocks" directives), I now have a updated idea:

invent nothing new, but only one non-breaking change:

there are several advantages:

template.ejs

<%- include(content, {foo: 'Foo'}) %>

page.ejs

<% const content = ({foo}) => {%>
<div><%=foo%></div>
<% }; %>
<%- include('./template', { content })%>

I'm able to get a local modification running, however the code is not prod ready. if @RyanZim and @mde agree on this, I'll happy to work on this.

Comments & thoughts are welcome!

mde commented 5 years ago

This could definitely work. One thing to keep in mind is that we ultimately want to support async/await for include.

huxia commented 5 years ago

Hello @mde @RyanZim . My proposal: https://github.com/huxia/ejs/pull/1/files (not finalized yet, issues listed below, but would be great if you guys could take a look and share your thoughts 🙏)

Good part:

Issues:

Detailed reason: the key for this implementation is to modify the "append" target during runtime. The scenario above for example: by the time the user's function("content" in page.ejs) is defined & parsed, the "append" is pointed to the included file(page.ejs). So when the function is executed in other place(template.ejs), the origin content-output order is wrong, needs manually reorder.

  • A "rootTemplate" property is invented to keep the relationships amount Template instances.
  • There are some problems to implement this feature along with "options.client" support, so the above code doesn't implemented it yet
    1. It will be harder to keep the relationships amount Template instances when options.client = true
    2. because the current implementation is to do the detection when ejs "include" is called, however, when options.client = true, developers will need to duplicate this detection logic in their include callback function. Maybe I need to provide a helper function? like below?
      let str = "<% let a = () => {%>Function Implementation<% }; %> Hello " 
      + "<%= include('file', {person: 'John'}); %><%- include(a)%>",
      fn = ejs.compile(str, {client: true});
      // the ejs.include is a helper function to "generate" a real include callback function, it does the "including-a-function detection logic" mentioned above.
      fn(data, null, ejs.include(path => clientTemplates[path])); 

      I don't have much experience on ejs client mode. So not sure on this, your suggestions needed.

ichiriac commented 5 years ago

Hi @huxia, does this feature is still planned ?

ichiriac commented 5 years ago

Hi,

Meanwhile I've made a workaround/hack in order to avoid extending - in my case I just needed the inheritance behavior (and it works with expressjs).

// ... expressjs bootstrap & routing ...
var layoutPath = path.join(__dirname, 'views', 'layouts');
var ejs = require('ejs');
var compile = ejs.compile;
ejs.compile = function(template, opts) {
  var fn = compile(template, opts);
  return function(locals) {
    var layout = null;
    locals.layout = function(name) {
      layout = name;
    };
    var output = fn.apply(this, arguments);
    if (layout) {
      var ext = path.extname(layout);
      if (!ext) {
        layout += '.ejs';
      }
      locals.contents = output;
      layout = path.resolve(layoutPath, layout);
      ejs.renderFile(layout, locals, opts, function(err, out) {
        if (err) {
          throw err;
        } else {
          output = out;
        }
      });
    }
    return output;
  };
};

And here the usage from an views/index.ejs :

<%_ layout("default"); _%>
<h1>Welcome</html>

And here my layout views/layouts/default.ejs :

<html>...
<body>
....
<%- contents; -%>
...
</body>
</html>

This little snippet not so intrusive and avoids extra dependencies but may break if renderFile executes the cb argument async (as it may should but it doesn't today)...

I think the simplest thing to do is to introduce on ejs an hook system on compile and then it would provide a way to implement new functions like inhertance or blocks out of the box...

I've made a quick & dirty prototype in order to see how the API could be, you can take a look at it here : https://github.com/ichiriac/ejs-decorator - tell me if you're interested in a PR

mde commented 5 years ago

A hook system, meaning make the Template class an EventEmitter?

ichiriac commented 5 years ago

Hi @mde, not yet sure how to achieve this, at the time I've started the comment I did not fully grasp the syntax capabilities, now I'm not so sure that would be a clean way to achieve layouts decoration.

I'm still prototyping, and searching a solution...

BTW you may be interested in this : https://github.com/ichiriac/ejs-next - same syntax but with promises support on files or outputs. The parser is about 10 times more efficient than regex, but I need to work on execution. I want to avoid reference errors when strict=false mode - and just fallback on empty entries, so I'm using slow Proxy traps :smile:

ichiriac commented 5 years ago

I think the best approach it @huxia's one, with a slightly difference.

Actually the problem comes from how to buffer inner output in order to redirect it into a variable or option, and pass it to the layout, or anywhere else.

EJS

<% var contents = () => {@ %>
  Hello <%= name %>
<% @} %>
or 
<% var contents = function() {@ %>
  Hello <%= name %>
<% @} %>

It's intuitive and keeps the idea of plain JS

JS

var contents = function(data) {
  var locals = locals.push(data);
  with(locals) {
    echo(`Hello `);
    echo(name);
  }
  return locals.resolveOutput();
};

May introduce changes on compiler, based on the following rule :

Also for the start part you need to detect the function prefix in order to rewrite it.

USAGE

<%= contents %>
<%= contents({ name: 'John Doe' }) %>
<%- include('layout.ejs', { contents }) %>
<%-
    include('layout.ejs', { 
      contents: function() {@ %> 
         Something here ...
      <% @},
      header: function() {@ %> 
         Something here ...
      <% @}
    })
%>

That will be my approach, it avoids extra syntax with <%* snippet foo %> that does not stick with JS and introduce a new concept of inner template parts or blocks that missed for layouts.

Next it will be easy to implement helpers like blocks dirrectly from a custom function ...

huxia commented 5 years ago

Hi @huxia, does this feature is still planned ?

sorry for the late reply, I would be glad to help with the code & pr, as long as @mde @RyanZim and other maintainer agrees on this approach.

@ichiriac agrees with you, I think there should be as little avoids extra syntax as possible

A hook system, meaning make the Template class an EventEmitter?

@mde my approach here is to introduce a global output stack, which could be toggled at runtime.

I guess by some feather modification, it could become something like a EventEmitter. there maybe some cool features could come from it, the only problem is, it looks like a big rewrite here -- which I'm not so sure, however I'll be willing to help if you guys can give a specific task 😄 .

ichiriac commented 5 years ago

Hi there, I've finished a first prototype of that implementation, with layout, blocks & async support - you can checkout the code here : https://github.com/ichiriac/ejs-next

I wanted to avoid overhead on the hook/decorator so I've keeped my implementation kiss/stupid : https://github.com/ichiriac/ejs-next/blob/master/lib/ejs.js#L103 / https://github.com/ichiriac/ejs-next/blob/master/lib/ejs.js#L188

huzunjie commented 5 years ago

One way to use extends/block in existing versions:

page.ejs

<% const body = __append => { -%>
  <h1>H1-text</h1>
  <div>content</div>
<% } -%>
<%-include('./base', { 
  title: 'PageTitle', 
  css: '<!--#css-html#-->', 
  body, 
  footer: '<!--#js-html#-->' 
})%>

base.ejs

<% const block = (name, def = '') => {
  const fn = locals[name];
  if(!fn) return def;
  if(typeof(fn)==='string') return fn;
  const arr = [];
  fn(txt=>arr.push(txt));
  return arr.join('');
}-%>
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
    <title>
      <%-block('title', 'No title')%>
      -
      Site Title
    </title>
    <%-block('head')%>
  </head>
  <body>
    <%-block('body', 'No body')%>
    <%-block('footer')%>
  </body>
</html>
rambo-panda commented 4 years ago

One way to use extends/block in existing versions:

page.ejs

<% const body = __append => { -%>
  <h1>H1-text</h1>
  <div>content</div>
<% } -%>
<%-include('./base', { 
  title: 'PageTitle', 
  css: '<!--#css-html#-->', 
  body, 
  footer: '<!--#js-html#-->' 
})%>

base.ejs

<% const block = (name, def = '') => {
  const fn = locals[name];
  if(!fn) return def;
  if(typeof(fn)==='string') return fn;
  const arr = [];
  fn(txt=>arr.push(txt));
  return arr.join('');
}-%>
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
    <title>
      <%-block('title', 'No title')%>
      -
      Site Title
    </title>
    <%-block('head')%>
  </head>
  <body>
    <%-block('body', 'No body')%>
    <%-block('footer')%>
  </body>
</html>

This is why I like ejs

forrestli74 commented 3 years ago

Without this being supported on the official library, is the any library that extends ejs and supports it? I would prefer not to use @huzunjie 's code because it uses internal variables and force me to define block function in all layouts.

@mde is there any comments on this proposal? https://github.com/mde/ejs/issues/252#issuecomment-439708783

syco commented 11 months ago

Any update to this conversation?