11ty / eleventy

A simpler site generator. Transforms a directory of templates (of varying types) into HTML.
https://www.11ty.dev/
MIT License
17.1k stars 494 forks source link

Access existing collections inside a shortcode. #813

Closed Wilto closed 3 years ago

Wilto commented 4 years ago

Describe the solution you'd like I’d like the contents of existing collections to be accessible from inside a shortcode.

Additional context I’m building a pattern library dealie, and I’d like to be able to compose “pages“ from individual “components.”

/_src/_components/hgroup/code.html

<div class="hgroup">
    <h2 class="callout-hed">Primary Heading</h3>
    <p>Secondary heading or lede</p>
</div>

/_src/_pages/blogpost/code.html

{% set comps = collections.components %}
<div class="copy">
    {% useComponent "hgroup", comps %}

    <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit…</p>
</div>

/_src/.eleventy.js

eleventyConfig.addShortcode("useComponent", function( compName, collection ) {
    singleComp = collection.filter(function( comp ) {
        return comp.inputPath.indexOf( compName + "/code.html" ) > -1;
    });
    return singleComp[ 0 ].template.frontMatter.content;
});

Needing to include {% set comps = collections.components %} on every composed page template and cobbling together a new, single-item collection inside the shortcode both feel a little iffy to me.

Ideally, since I already have a collection containing all the components, I’d like to filter on that and return the result from the existing collection.

Wilto commented 4 years ago

ps. eleventy rulez ok

monochromer commented 3 years ago

we have access to page via this.page, but why don't we have access to this.collections?

cmcknight commented 3 years ago

I have the same issue with regard to collection access in shortcodes. My current workaround is the following:

eleventyConfig.addShortcode('getMfr', mfrId => {
  let mfrData = JSON.parse(fs.readFileSync('./src/_data/manufacturers.json'))
  let mfr = mfrData.find(obj => obj.mfrId === mfrId)
  let text = `<a href="${mfr.url}" target="_blank">${mfr.name}</a>`
  return text
}

Called with:

{% getMfr product.mfrId  %}

It feel ugly, but it works.

zachleat commented 3 years ago

This repository is now using lodash style issue management for enhancements. This means enhancement issues will now be closed instead of leaving them open.

View the enhancement backlog here. Don’t forget to upvote the top comment with 👍!

cfjedimaster commented 3 years ago

If I'd like to ensure this also adds access to filters, would I file a new bug?

sentience commented 2 years ago

I'm hoping to get collection access inside of shortcakes in order to facilitate migration from Jekyll. In particular, Jekyll's page_url shortcode for obtaining the URL of an item in the "pages" collection (by providing its slug as an argument) is a core feature of Jekyll that currently has no direct equivalent in Eleventy.

Being able to access this.collections inside of a shortcode definition would solve this neatly.

pdehaan commented 2 years ago

I have a potential hacky workaround, but I don't know that I really like it… I don't know if this would always be guaranteed to work, and my 2 file test case probably isn't that great. I'm just abusing my weak assumption that the .addCollection() code would always complete before we ever call a custom shortcode.

I assume there's a dozen of ways this wouldn't work in the real world, but who knows…

// .eleventy.js
module.exports = function (eleventyConfig) {
  // Create a "semi-global" `$pages` array (which will be populated later via a custom "shadow" collection).
  let $pages = [];

  // I have no idea how the Jekyll `page_url` shortcode works internally, so this is a rough guess. 🤷 
  eleventyConfig.addShortcode("page_url", function (slug = "") {
    // Try and find the specified page slug in our `$pages[]` array by matching the
    // template's `filePathStem`.
    const page = $pages.find((page) => page.filePathStem === slug);
    // No match found, hard error (again, no idea how Jekyll works, but I liked the idea
    // of failing fast-and-furious if we have bad links.
    if (!page) {
      throw new Error(`Unknown page slug: "${slug}"`);
    }
    // We had a match, so return the page's `url`.
    return page.url;
  });

  // Create a custom "pages" collection, which basically copies/mirrors the items that
  // are already tagged "pages".
  // Seems silly, but we really are just using this to copy the collection into our semi-global
  // `$pages[]` array for lookup by our custom shortcode.
  eleventyConfig.addCollection("pages", function (collectionApi) {
    $pages = [...collectionApi.getFilteredByTag("pages")];
    return $pages;
  });

  return {
    dir: {
      input: "src",
      output: "www",
    },
  };
};

And I can use it like this:

---
# src/index.liquid
title: Page One
---

{% page_url "/nested/page-two" %}
---
# src/nested/page-two.liquid
title: Page Two
---

HOME: {% page_url "/index" %}
{% comment %}
This page slug doesn't exist, so will throw a hard error.
BAD: {% page_url "beep boop" %}
{% endcomment %}
// src/src.11tydata.js
module.exports = {
  // All all the pages to a "pages" collection. Not sure why, although you did mention a "pages" collection.
  tags: ["pages"]
};

OUTPUT

<!-- www/index.html -->
/nested/page-two/
<!-- www/nested/page-two/index.html -->
HOME: /

And for conversation's sake, here's the console output when using our very naughty {% page_url "beep boop" %} shortcode w/ an "invalid" slug.

> Executing task: npm run build <

> 11ty-813@1.0.0 build
> eleventy

[11ty] Problem writing Eleventy templates: (more in DEBUG output)
[11ty] > Having trouble rendering liquid template ./src/nested/page-two.liquid

`TemplateContentRenderError` was thrown
[11ty] > Unknown page slug: "beep boop", file:./src/nested/page-two.liquid, line:3, col:6

`RenderError` was thrown
[11ty] > Unknown page slug: "beep boop"

`Error` was thrown:
[11ty]     Error: Unknown page slug: "beep boop"
        at Object.<anonymous> (/private/tmp/11ty-813/.eleventy.js:7:13)
        at Object.<anonymous> (/private/tmp/11ty-813/node_modules/@11ty/eleventy/src/BenchmarkGroup.js:32:26)
        at Object.render (/private/tmp/11ty-813/node_modules/@11ty/eleventy/src/Engines/Liquid.js:152:25)
        at async Template._render (/private/tmp/11ty-813/node_modules/@11ty/eleventy/src/TemplateContent.js:421:22)
        at async Template.getTemplateMapContent (/private/tmp/11ty-813/node_modules/@11ty/eleventy/src/Template.js:1056:19)
        at async TemplateMap.populateContentDataInMap (/private/tmp/11ty-813/node_modules/@11ty/eleventy/src/TemplateMap.js:461:39)
        at async TemplateMap.cache (/private/tmp/11ty-813/node_modules/@11ty/eleventy/src/TemplateMap.js:360:5)
        at async TemplateWriter._createTemplateMap (/private/tmp/11ty-813/node_modules/@11ty/eleventy/src/TemplateWriter.js:242:5)
        at async TemplateWriter.generateTemplates (/private/tmp/11ty-813/node_modules/@11ty/eleventy/src/TemplateWriter.js:275:5)
        at async TemplateWriter.write (/private/tmp/11ty-813/node_modules/@11ty/eleventy/src/TemplateWriter.js:321:23)
[11ty] Wrote 0 files in 0.02 seconds (v1.0.0)
The terminal process "/bin/zsh '-c', 'npm run build'" terminated with exit code: 1.
sentience commented 2 years ago

I've got this working as a Liquid Custom Tag, which does seem to have access to collections:

  // Implement Jekyll's post_url tag
  // Usage: {% post_url post-filename-without-extension %}
  eleventyConfig.addLiquidTag("post_url", function (liquidEngine) {
    return {
      parse: function (tagToken, remainingTokens) {
        this.str = tagToken.args; // post-filename-without-extension
      },
      render: async function (context) {
        const postFilenameWithoutExtension = `./_posts/${this.str}`;
        const posts = context.environments.collections.post;
        const post = posts.find((p) =>
          p.inputPath.startsWith(postFilenameWithoutExtension)
        );
        if (post === undefined) {
          throw new Error(`${this.str} not found in posts collection.`);
        } else {
          return post.url;
        }
      },
    };
  });
pdehaan commented 2 years ago

Also, if you're using LiquidJS, you might be able to get access to collections in custom filters via this.context.environments.collections (assuming you're not using arrow functions):

  eleventyConfig.addFilter("page_url", function (slug = "") {
    const pages = this.context.environments.collections.pages;
    const page = pages.find(p => p.filePathStem === slug);
    if (page) {
      return page.url;
    }
    throw new Error(`Unknown page slug: "${slug}"`);
  });

Actually, it looks like Nunjucks might have access to collections when using non-arrow functions as well via this.ctx.collections object.

This hot mess might work for both LiquidJS and Nunjucks. I'd have to play some more to see if if I can get it working with JavaScript/Eleventy templates:

  eleventyConfig.addFilter("page_url", function (slug = "") {
    const collections = this.ctx?.collections || // Nunjucks
      this.context?.environments.collections || // LiquidJS
      {}; // Default to an empty object.
    // Use the custom `pages` collection, or else default to an empty array.
    const pages = collections.pages || [];
    const page = pages.find(p => p.filePathStem === slug);
    if (page) {
      // We found a matching slug/filePathStem, return the page's `url` property.
      return page.url;
    }
    // ABORT! ABORT!
    throw new Error(`Unknown page slug: "${slug}"`);
  });
pdehaan commented 2 years ago

@sentience That's super cool!

I was reading https://liquidjs.com/tutorials/register-filters-tags.html and wasn't sure if you want to use this.str directly, versus something like const slug = await liquidEngine.evalValue(this.str, ctx);

By running evalValue() on the this.str value, it let me pass raw strings, or use frontmatter keys in the custom tag.

---
title: Page One
var: one
---

{% post_url3 "one" %}
{% post_url3 var %}

My slightly modified custom tag looks something like this:

  eleventyConfig.addLiquidTag("post_url", function (liquidEngine) {
    return {
      parse(tagToken, remainingTokens = []) {
        this.str = tagToken.args;
      },
      async render(ctx) {
        const slug = await liquidEngine.evalValue(this.str, ctx);
        const postFilename = `./src/_posts/${slug}`;
        const posts = ctx.environments.collections.post;
        const post = posts.find(p => p.inputPath.startsWith(postFilename));
        if (post) {
          return post.url;
        }
        throw new Error(`${slug} not found in post collection.`);
      },
    };
  });
sentience commented 2 years ago

@pdehaan To emulate Jekyll's post_url as closely as possible, you want to be able to pass the post filename without quotes (i.e. {% post_url post-foo %} not {% post_url "post-foo" %}), I don't want to use Liquid's evalValue. If you want a souped up post_url that can accept dynamic values (but require quotes around a literal string), then your approach definitely makes sense.

cfjedimaster commented 10 months ago

I'm now encountering a need for this in eleventy.after. I was surprised that it wasn't available.