mde / ejs

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

Is component/partial composition possible? #437

Open stowball opened 5 years ago

stowball commented 5 years ago

I've read through quite a few of the open issues and PRs about "blocks" etc, but I'm unclear whether they solve my particular problem, or whether my problem is already achievable so please forgive me for logging this "issue" if it is.

I'm using Eleventy, a static site generator to build a website. It supports a lot of templating languages, but EJS] has been my favourite to work with in that it's "just JavaScript", except, I cannot figure out how to do component composition, or whether it's at all possible. The EJS docs are… lacking.

What I want to achieve is similar to what you'd do in React or Vue with props.children or <slot>, where you compose components, but it would obviously be done with partials here.

For example, say I had a grid-item partial that accepted sizes it could render at, its tag name and "children", something like:

<%- include('../grid-item', {
  size: '50',
  tag: 'li',
  children: ANOTHER-INCLUDE,
}); %>

However, even the above example is a bit limiting, so what I'd really like to achieve is something like:

<%- include('../grid', {
  tag: 'ul',
}, () => { %>
  <%- include('../grid-item', {
    size: '50',
    tag: 'li',
  }, () => { %>
    <div>ANY MARKUP</div>
  <% }); %>
<% }); %>

Is this possible? Am I approaching this from the wrong angle?

Eleventy supports a workaround for this with Paired Shortcodes for Nunjucks and Liquid, but both of those templating languages seem severely limited. Handlebars supports this via its @partial-block syntax, but wrangling Handlebars to even remotely do what's possible in EJS is excruciating.

Any help would be greatly appreciated.

mde commented 5 years ago

I'll take a look at Paired Shortcodes, and the partial-block syntax. But I have to admit I'm curious why anyone would want to organize their code the second way. Why would you not have the grid-item include inside the grid include, and just pass the needed data? Seems like cleaner code organization. I keep seeing the request for blocks, but I have a hard time understanding what actual problem it's trying to solve.

stowball commented 5 years ago

Well, a grid would contain any number of grid items, which in turn could contain any arbitrary children including nested grids, so grid-item couldn't be included in grid by default.

Here's an example of the code I've currently had to write, which hopefully illustrates the issues. It has 5 grid items within 1 grid.

<%- include('../grid/start', {
  className: styles.root,
  tag: 'footer',
}); %>
  <%- include('../grid/cell-start', {
    className: styles.gridCell + styles.imageCell,
  }); %>
    <a
      aria-label="Home"
      href="/"
    >
      <img
        class="<%= styles.image %>"
        alt="foobar logo"
        height="98"
        src="/images/logo-wordmark-white.svg"
        width="211"
      />
    </a>
  <%- include('../grid/cell-end'); %>

   <%- include('../grid/cell-start', {
    className: styles.gridCell,
  }); %>
    <%- include('./newsletter', {
      legendClassName: styles.primaryText + styles.primaryTextTitle,
    }); %>
  <%- include('../grid/cell-end'); %>

   <%- include('../grid/cell-start', {
    className: styles.gridCell,
  }); %>
    <h3
      class="
        <%= styles.primaryText %>
        <%= styles.primaryTextTitle %>
    ">
      Follow us
      <span class="<%= styles.primaryTextSpacer %>">&nbsp;</span>
    </h3>
    <ul class="<%= styles.socialList %>">
      <% socialNetworks.forEach((network) => { %>
        <li class="<%= styles.socialListItem %>">
          <%- include('../social-icon/index', {
            name: network.name,
            url: network.url,
          }); %>
        </li>
      <% }); %>
    </ul>
  <%- include('../grid/cell-end'); %>

   <%- include('../grid/cell-start', {
    className: styles.gridCell + styles.linkWrapper,
  }); %>
    <h3 class="
      <%= styles.primaryText %>
      <%= styles.primaryTextTitle %>
    ">
      Contact Us
      <span class="<%= styles.primaryTextSpacer %>">&nbsp;</span>
    </h3>
    <a
      class="
        <%= styles.primaryText %>
        <%= styles.link %>
      "
      href="tel:1-800-000-0000"
      >
      1-800-000-0000
    </a>

     <a
      class="
        <%= styles.primaryText %>
        <%= styles.link %>
      "
      href="mailto:info@foobar.com"
    >
      info@foobar.com
    </a>
  <%- include('../grid/cell-end'); %>

   <%- include('../grid/cell-start', {
    className: styles.gridCell,
  }); %>
    <p class="<%= styles.blurb %>">
      This is a fictitious company created by Foobar Corporation, or its affiliates, solely for the creation and development of educational training materials. Any resemblance to real products or services is purely coincidental. Information provided about the products or services is also fictitious and should not be construed as representative of actual products or services on the market in a similar product or service category.
    </p>
  <%- include('../grid/cell-end'); %>
<%- include('../grid/end', {
  tag: 'footer',
}); %>

Unfortunately, by not being able to compose partials, I have to create start and end partials for each item, and use nesting to "enforce" their display rules, which is not ideal.

Even shortcodes and Handlebars' syntax falls short in my opinion, because as soon as you start introducing a custom start and end tag syntax you're asking for trouble, which is why in my original example, I was hoping that some kind of callback mechanism existed, which would enforce the nesting/composition, and allow you to pass arguments to the "children" as well.

HTH

stowball commented 5 years ago

Another good use case example is a container component, which can be re-used within a header, main content modules and the footer to create a centered, max-width element within a full-bleed container, which in Vue would be created like this:

<script>
export default {
  styles: `
    margin-horizontal:auto
    max-width:container
    padding-horizontal:24
    @mq-768--padding-horizontal:32
  `,
  props: {
    as: {
      default: 'div',
      type: String,
    },
  },
};
</script>

<template>
  <component
    v-bind:class="styles"
    v-bind:is="as"
  >
    <slot></slot>
  </component>
</template>

and would be used in multiple components like so:

<template>
  <header>
    <container>
      logo, nav etc
    </container>
  </header>

  <container as="main">
    body content
  </container>

  <footer>
    <container>
      social icons etc
    </container>
  </footer>
</template>

Thinking how Vue and React handle it, perhaps a closing tag syntax isn't that bad 😊

stowball commented 5 years ago

Any thoughts on this?

DaveOrDead commented 4 years ago

It seems to me that using closing tag syntax could be a good way to go. You could also do it via a parameter passed to the same component file so that your opening and closing tags for a component remain together.

E.g

<%- include('grid/grid', {className: 'foo'}); %>

    <%- include('grid/cell', {className: 'bar'}); %>

        <p>Some child content</p>

    <%- include('grid/cell', {door: 'close'}); %>

<%- include('grid/grid', {door: 'close'); %>

door is just an example, I'm sure something more semantic could be thought of - I was just thinking of something that opens and closes. (Even self-close I guess)

Then in the component file grid/cell.ejs you can have:

<% if (locals.door !== 'close') { %>

    <div class="l-grid__item <%= className %>">

<% } %>

// Children render here

<% if (locals.door === 'close') { %>

    </div>

<% } %>

Personally I think it's nice being able to see all the code for a component in the same place without needing to have a separate start and end file