MENU
Elder Guide ™
SearchSearch at your location
Nursing HomesAssisted LivingSenior LivingPlanning
MissionExperts
  • HomeA home icon, used to navigate home.
  • Technology
  • Elder.js

Elder.js: An Opinionated, SEO focused, Svelte Framework.

Star

Elder.js is an opinionated static site generator and web framework built with SEO in mind. (Supports SSR and Static Site Generation.)

Features:

  • Build hooks allow you to plug into any part of an entire page generation process and customize as needed.
  • A Highly Optimized Build Process: that will span as many CPU cores as you can throw at it to make building your site as fast as possible. For reference, Elder.js easily generates a data intensive 18,000 page site in 8 minutes using a budget 4 core VM.
  • Svelte Everywhere: Use Svelte for your SSR templates and with partial hydration on the client for tiny html/bundle sizes.
  • Straightforward Data Flow: By simply associating a data function in your route.js, you have complete control over how you fetch, prepare, and manipulate data before sending it to your Svelte template. Anything you can do in Node.js, you can do to fetch your data. Multiple data sources, no problem.
  • Community Plugins: Easily extend what your Elder.js site can do by adding prebuilt plugins to your site.
  • Shortcodes: Future proof your content, whether it lives in a CMS or in static files using smart placeholders.
  • 0KB JS: Defaults to 0KB of JS if your page doesn't need JS.
  • Partial Hydration: Unlike most frameworks, Elder.js lets you hydrate just the parts of the client that need to be interactive allowing you to dramatically reduce your payloads while still having full control over component lazy-loading, preloading, and eager-loading.

Project Status: Stable

Elder.js is stable and production ready.

It is being used on this site and 2 other flagship SEO properties that are managed by the maintainers of this project.

We believe Elder.js has reached a level of maturity where we have achieved the majority of the vision we had for the project when we set out to build a static site generator.

Our goal is to keep the hookInterface, plugin interface, and general structure of the project as static as possible.

This is a lot of words to say we’re not looking to ship a bunch of breaking changes any time soon, but will be shipping bug fixes and incremental changes that are mostly “under the hood.”

As of September 2020, the ElderGuide.com team expects to maintain this project at least until 2023-2024. For a clearer vision of what we mean by this and what to expect from the maintainers as far as what is considered "in scope" and what isn't, please see this comment.

Context

Elder.js is the result of our team's work to build this site (ElderGuide.com) and was purpose built to solve the unique challenges of building flagship SEO sites with 10-100k+ pages.

Elder Guide Co-Founder Nick Reese has built or managed 5 major SEO properties over the past 14 years. After leading the transition of several complex sites to static site generators, he loved the benefits of the JAM stack, but wished there was a better solution for complex, data intensive, projects. Elder.js is his vision for how static site generators can become viable for sites of all sizes regardless of the number of pages or how complex the data being presented is.

We hope you find this project useful whether you're building a small personal blog or a flagship SEO site that impacts millions of users.

Getting Started

The quickest way to get started is to get started with the Elder.js template using degit:

Step 1: Clone Template

npx degit Elderjs/template elderjs-app
cd elderjs-app
yarn # or npm install

Step 2: Start the Project

npm start

Navigate to http://localhost:3000. You should see your app running.

You can also see a live demo of this template: https://elderjs.netlify.app/

Developing using the Template:

For development, we recommend running two separate terminals. One for the server and the other for rollup.

Terminal 1: Server

npm run dev:server # `npm start` above starts a server, but doesn't rebuild your Svelte components on change.

Terminal 2: Rollup

npm run dev:rollup # This rebuilds your Svelte components on change.

Once you have these two terminals open, edit a component file in src, save it, and reload the page to see your changes.

To Build/Serve HTML:

npm run build

Let the build finish.

npx sirv-cli public

Elder.js Intro Video:

The talk below was given at Svelte Summit 2020 and is a great intro to the concepts behind Elder.js.

Why We Built Elder.js

When we set out to build elderguide.com we tested 6 different static site generators (Gatsby, Next.js, Nuxt.js, 11ty, Sapper and Hydrogen.js) and ultimately realized there wasn’t a solution that ticked all of our boxes.

On our journey, we had 3 major realizations:

  1. Most SSGs are built for either simple sites/blogs or for full scale "app frameworks" that have added an 'export' process added as an afterthought.
  2. Fetching data from multiple sources (dbs, apis, config files, markdown files) can lead to major code spaghetti.
  3. Client side routing adds a huge amount of complexity (and bundle size) to initial loads for very little SEO benefit. If you aren’t building an App, why would we want to fully hydrate our JS framework just for faster routing? Browsers are great at routing… we should only be hydrating things that need to be hydrated.

Initially, we decided to go with Sapper but hit major data roadblocks and issues unusable build times and development reload times.

In an afternoon of frustration, we whipped up a very rudimentary SSG with a complex and error prone process of adding Svelte components… but it worked. #productionready

After shipping ElderGuide.com to production, we were working on a refactor when a moment of genius from Kevin over at Svelte School prompted a major breakthrough that allowed us to use Svelte 100% for templating and still get partial hydration even though Svelte doesn’t support it.

After much consideration, we decided to open source the project so others could use it.

We can't wait to see what you build with it.

Routes

At the core of any site are its "routes" or templates.

In Elder.js a route is made up of 2 files that live in your route folder: ./src/routes/${routeName}/.

They are:

  1. A route.js file. This is where you define route details such as the route’s permalink function, all function and data function.
  2. A Svelte component to be used as a template matches the ${routeName}; eg: ./src/routes/blog/Blog.svelte (from here on out we refer to these specific Svelte components as "Svelte Templates")

Route.js

route.js files consist of a permalink function, an all function, and a data function.

Elder.js uses "explicit routing" instead of the more common "parameter based" routing found in most frameworks like express.

At first, Elder.js' non-conventional routing can be intimidating, but it offers some major benefits discussed below while streamlining data flow in complex sites.

Let's look at an example of how you'd setup a route like /blog/:slug/ where there are only 2 blogposts.

// ./src/routes/blog/route.js
module.exports = {
  template: 'Blog.svelte',
  permalink: ({ request }) => `/blog/${request.slug}/`, // this is the same as /blog/:slug/ in 'parameter based' routing.
  all: async () => {
    // The all function returns an array of all possible "request" objects for a route.
    // Here we are explicitly defining every possible variation of this route.
    return [{ slug: 'blogpost-1' }, {slug: 'blogpost-2'}],
  },
  data: async ({ request }) => {
  // The object returned here will be available in the Blog.svelte as the 'data' prop.
  return {
    blogpost: `This is the blogpost for the slug: ${request.slug}`.
  }
};

Here is what is happening in plain English:

  • permalink(): The permalink function is similar to your standard route definition you'd see with placeholders. This means /blog/:slug/ would be defined as /blog/${request.slug}/. The permalink function's job is to take the request objects returned from all and transform them into relative urls.
  • all(): This async function returns an array of all of the request objects for a given route. Often this array may come from a data store but in this example, we're explicitly saying we only have 2 blog posts, so only two pages will be generated.
  • data(): The data function prepares the data required in the Blog.svelte file. Whatever object is returned will be available as the data prop. In the example, we are just returning a static string, but you could also hit an external CMS, query a database, or read from the file system. Anything you can do in node, you can do here.

In this example, we're just returning a simple object in our data() function, but we could have easily used node-fetch and gotten our blogpost from a CMS or used fs to read from the filesystem:

const blogpost = await fetch(
  `https://api.mycms.com/getBySlug/${request.slug}/`
).then((res) => res.json());

Why Routing Differs from Express-like Frameworks

Elder.js' approach to routing is unconventional but it offers several distinct advantages, the two biggest are:

  1. Unlike traditional 'parameter based' routing, Elder.js' does not have to crawl all of the links of a site to know what pages need to be generated. This allows for fully parallelized build times that scale with CPU resources. (As of October 2020, ElderGuide.com has ~20k pages and builds in 1 minute 22 seconds.)
  2. Users have full control over their URL structure. No complex regex is needed to have /senior-living/:facilityId/ and /senior-living/:articleId/ and /senior-living/:parentCompanyId/. This also makes i18n and l10n much more approachable.

Route.js Best Practices:

With the simple route.js example out of the way, let's talk about best practices and let's look at a more complex example of a route.js file.

Best Practice: A route's all function should return the minimum viable data points needed to generate a page.

Skinny request objects. Fat data functions.

When people first encounter Elder.js, there is a strong temptation to load the request objects returned by a route's all function with tons of data.

While this approach works, it doesn't scale very well. Fetching, preparing, and processing data should be done in your data function.

That said, it is recommended that you only include the bare minimum required to query your database, api, file system, or data store on the request object. From there, do all of the data fetching, preparing, and organization in the route's data function.

Real World Example

To drive this point home and to show a more complex example of routing, imagine you're building a travel site that lists tourist attractions for major cities throughout the world.

You have a city route and for each page on that route you need 3 data points to query your API, database, or datastore in order to pull in all of the rest of the page's data.

These data points are:

  1. The language of the page being generated
  2. The City slug
  3. The Country slug

Here is what a minimal route.js would look like to support /en/spain/barcelona/ and /es/espana/barcelona/.

// ./src/routes/city/route.js
module.exports = {
  permalink: ({ request, settings }) =>
    `/${request.lang}/${request.country.slug}/${request.slug}/`,
  all: async () => {
    return [
      { slug: "barcelona", country: { slug: "spain" }, lang: "en" },
      { slug: "barcelona", country: { slug: "espana" }, lang: "es" },
    ];
  },
  data: async ({ request }) => {
    // discussed below.
  },
};

Problems with Fat Request Objects

Imagine for a moment that we attempted to include all of the additional details needed to generate the page for this route in our request objects like so:

module.exports = {
  // permalink function
  all: async () => {
    return [
      { slug: 'barcelona', country: { slug: 'spain' }, lang: 'en', data: { hotels: 12, attractions: 14, promotions: ['English promotion'], ...lotsOfData } },
      { slug: 'barcelona', country: { slug: 'espana' }, lang: 'es' data: { hotels: 12, attractions: 14, promotions: ['Spanish promotion'], ...lotsOfData } }
    ]
  }
  // data function
}

Now imagine your data function looks like so and you're getting more data.

module.exports = {
  // permalink function
  all: async () => {
    return [
      { slug: 'barcelona', country: { slug: 'spain' }, lang: 'en', data: { hotels: 12, attractions: 14, promotions: ['English promotion'], ...lotsOfData } },
      { slug: 'barcelona', country: { slug: 'espana' }, lang: 'es' data: { hotels: 12, attractions: 14, promotions: ['Spanish promotion'], ...lotsOfData } }
    ]
  },
  data:  async ({ request }) => {
    const hotels = [
        { ...hotel }, // imagine this has a lot of details
        { ...hotel },
        { ...hotel },
        { ...hotel },
        { ...hotel },
      ];

    // this will now be available in your svelte template as your 'data' param.
    // you could access all of the hotel details at `data.hotels`
    return {
      hotels,
    };
  },
}

With this implementation, you've now got both request and data objects in Svelte templates and you're asking yourself:

Should I be accessing request.data.hotels or just data.hotels.length to get the number of hotels?

Save yourself this headache by remembering: skinny request objects, fat data functions.

Only store the minimum data needed on your request objects. Instead return all of the data required by the page from the data function.

Note: If you're interested in i18n please look at this issue as robust support could be offered by a community plugin.

Database Connections, APIs, and External Data Sources

The data function of each route is designed to be the central place to fetch data for a route but the implementation details are very open ended and up to you.

Just about anything you can do in Node.js, you can do in a data function.

That said, if you are hitting a DB and want to manage your connection in a reusable fashion, the recommended way of doing so is to populate the query object on the bootstrap hook.

Using this pattern allows you to share a database connection across the entire lifecycle of your Elder.js site.

Cache Data Where Possible Within Route.js Files

If you have a data heavy calculation required to generate a page, look into calculating that data and caching it before your module.exports definition like so:

// ./src/routes/city/route.js

// do heavy calculation here
// this prevents the data from being calculated each request
const cityLookupObject = {
   barcelona: {
   // lots of data.
  }
}

module.exports = {
  permalink: ({ request, settings }) =>
    `/${request.lang}/${request.country.slug}/${request.slug}/`,
  all: async () => {
    return [
      { slug: "barcelona", country: { slug: "spain" }, lang: "en" },
      { slug: "barcelona", country: { slug: "espana" }, lang: "es" },
    ];
  },
  data: async ({ request }) => {

    return {
      city: cityLookupObject[request.slug];
    }

  },
};

Data Used in Multiple Routes

If you have data that is used in multiple routes, you can share that data between routes by populating the data object on the bootstrap hook documented later in this guide.

Assuming you have populated the data.cities with an array of cities on the bootstrap hook, you could access it like so:

// ./src/routes/city/route.js
module.exports = {
  permalink: ({ request }) => `/${request.slug}/`,
  all: async ({ data }) => data.cities,
  data: async ({ request, data }) => {
    return {
      city: data.cities.find(city=> city.slug === request.slug);
    }

  },
};

Data defined in bootstrap is available on all routes.

all() Function Spec

Here is the function signature for a route.js all function:

all: async ({ settings, query, data, helpers }): Array<Object> => {
  // settings: this describes the Elder.js settings at initialization.
  // query: an empty object that is usually populated on the 'bootstrap' hook with a database connection or api connection. This is sharable throughout all hooks, functions, and shortcodes.
  // data: any data set on the 'bootstrap' hook.
  return Array<Object>;
}

permalink() Function Spec

Here is the function signature for a route.js permalink function:

permalink: ({ request, settings, helpers }): String => {
  // NOTE: permalink must be sync. Async is not supported.

  // request: this is the object received from the all() function. Generally, we recommend passing a 'slug' parameter but you can use any naming you want.
  // settings: this describes the Elder.js bootstrap settings.
  // helpers: Elder.js helpers and user helpers from the ./src/helpers/index.js` file.
  // NOTE: You should avoid using helpers here as helpers.permalinks default helper (see below) doesn't support it.
  return String;
};

data() Function Spec

Whether you’re building a personal blog or complex data driven SEO site, a route's data function is the recommended place to fetch (from a db, api, or other source) and prepare data to be consumed by your Svelte templates.

Here is the function signature for a route.js data function:

data: async ({
  data, // any data set by plugins or hooks on the 'bootstrap' hook
  helpers, // Elder.js helpers and user helpers from the ./src/helpers/index.js` file.
  allRequests, // all of the `request` objects returned by a route's all() function.
  settings, // settings of Elder.js
  request, // the requested page's `request` object.
  errors, // any errors
  perf, // the performance helper.
  query, // search for 'query' in these docs for more details on it's use.
}): Object => {
  // data is any data set from plugins or hooks.
  return Object;
};

Hooks

Elder.js hooks are designed to be modular, sharable, and easily bundled in to Elder.js plugins for common use cases... while still giving developers of all skill levels an easy way to customize core page generation logic to their own needs.

For a full overview of the hooks available, you can reference the hookInterface.ts or the hooks list below.

In short, there is a hook at every major step of the page generation process from system bootstrap (the bootstrap hook) all the way to writing html to your computer (on the requestComplete hook).

The Goal of Elder.js Hooks

No project becomes a 'tangled mess' on day one. It happens over time.

You or someone on your team makes a small "hacky" fix.

This change was intended to be temporary but it falls off your team's radar.

Over time, these "hacky" fixes build up and slowly make a project hard to reason about and hard to work on.

The goal of Elder.js' hook implementation is that any changes that don't fit in a route.js file are instead aggregated in a single hooks.js file where anyone on a team will know to expect to find any hidden complexity.

The result of this approach is that of a project's hacky fixes are no longer scattered across a project, but instead live in a single self documenting location where users have complete but predictable control over the Elder.js page generation process.

The added benefit is that plugins can also tap into these hooks offering sharable functionality.

Hook Interface: the mutable and props Arrays

Each Elder.js hook explicitly defines which props are available to a function registered on a hook along with which of those props are mutable by that function.

This defines the "contract" that Elder.js' hook interface implements.

  • props represents the parameters that are available to a function registered on a hook.
  • mutable represents which of the props can be changed on a specific hook.

This structure was implemented to keep mutation and side effects predictable.

Under the hood, all items in the props array that aren't in the mutable array are passed as a Proxy.

Hook Lifecycle

Elder.js hook Lifecycle

Hook List

'customizeHooks'

Used to modify what hooks can mutate which properties all hooks.

Props:
hookInterface
errors
Mutable:
hookInterface
errors

This hook receives the hookInterface.ts file which defines all hook interactions. You can customize all 'props' and 'mutable' of all hooks by using this hook. This is a power user hook and unless you know Elder.js internals don't mess with it.

Advanced Hook: This hook is designed for plugins and power users who want to custom Elder.js beyond the common use cases.

Experimental · Location: Elder.ts

'bootstrap'

Routes, plugins, and hooks have been collected and validated.

Props:
helpers
data
settings
routes
hooks
query
errors
Mutable:
errors
helpers
data
settings
query
  • Often used to populate the empty query object with a database or API connection as query is passed to the all() function which is used to generate request objects.
  • Internally used to automatically populate the helpers object with the helpers found in './src/helpers/index.js'.
  • Can be used to set information on the data object that is needed throughout the entire lifecycle. (sitewide settings)
Stable · Location: Elder.ts

'allRequests'

allRequests which represents all of the request objects have been collected from route and plugins. This makes the 'allRequests' array mutable.

Props:
helpers
data
settings
allRequests
routes
query
errors
Mutable:
errors
allRequests

The main use here is to allow users to adjust the requests that Elder.js is aware of.

  • This could be used for incremental builds. By filtering and overwriting the allRequests array building just a single route or even a single request is doable.
  • This hook is used by elderjs-plugin-random to register temporary requests that it later intercepts to redirect to a random page of a route.
  • This hook is used by elderjs-plugin-markdown to register processed markdown files and their slugs Elder.js

NOTE: If you are modifying 'allRequests' you must set 'request.route' key for each request.

Stable · Location: Elder.ts

'middleware'

Fired upon a request that originates from the express/polka middleware version of Elder.js. The hook has access to "req" and "next" common in express like middleware.

Props:
errors
query
helpers
data
settings
allRequests
routes
req
next
res
serverLookupObject
runHook
shortcodes
request
Mutable:
errors
query
helpers
data
settings
allRequests
routes
req
next
res
request

If you're looking to use Elder.js with express/polka to build a server rendered website, then you'll be interested in this hook as it includes the familiar 'req' and 'next' objects as often used in Express middleware.

  • Under the hook Elder.js uses this hook to power the server implementation.
  • If you want to change the route of a request, you can do so by modifying the 'request.route' to the name of the new request, and it will be picked up by the default Elder.js server.
  • If you're looking to set user or session information stored on the 'req' prop we recommend using a hook to modify the 'request' object or 'data' objects. Change to the request object will be passed down.
  • If you're looking to pass in details about the query string deeper into your application, you could use this hook to do so.
  • Anything you'd use an Express 'req' or 'next' for you can do and customize other parts of the Elder.js on this hook.

Advanced Hook: This hook is designed for plugins and power users who want to custom Elder.js beyond the common use cases.

Stable · Location: prepareServer.ts

'request'

This is executed at the beginning the request object being processed.

Props:
helpers
data
settings
request
allRequests
query
errors
routes
route
Mutable:
errors
helpers
data
settings
request
route

This hook gives access to the entire state of a request lifecycle before it starts.

  • Primarily used to set 'request' specific data that is required by all routes so doesn't make sense to share across multiple 'data' functions.
  • If you have helper functions that need a closure isolated to a specific page generation lifecycle here is where you should attach them.
  • If you need to programmatically change the route, you can do so here. This is how the elderjs-plugin-random works.
  • This hook is commonly uses by plugins that need to add route level data that is dependent on the request to populate.
Stable · Location: Page.ts

'data'

This hook is run after the route's "data" function has executed.

Props:
data
request
errors
helpers
query
routes
cssStack
headStack
beforeHydrateStack
hydrateStack
customJsStack
footerStack
settings
Mutable:
errors
data
cssStack
headStack
beforeHydrateStack
hydrateStack
customJsStack
footerStack

This hook is mainly used by plugins/hooks to offer functionality at the route level that is dependent on the route's "data" function has returning but isn't suitable to live in multiple data function across many routes due to code duplication.

Examples of things we (ElderGuide.com) have done or have seen users do:

  • LD+JSON: Plugins/hooks that add LD+JSON may need the a route's "data" function to be executed before they have the data needed to run.
  • Breadcrumbs: Plugins/hooks that add breadcrumbs may be dependent on the "data" function of a route.
  • Table Of Contents: Plugins/hooks that automatically generate a table of contents will be dependent on data from a route's data function.
  • Reference Plugins: Plugins/hooks that collect references from content and add them to the footer of the page content.
  • Last Updated Data: Determining the last updated date for a page is often better to do in a central place instead of in many "data" functions.

Stacks are made available here so that strings can be added to the head or footer of the page easily.

Advanced Hook: This hook is designed for plugins and power users who want to custom Elder.js beyond the common use cases.

Stable · Location: Page.ts

'shortcodes'

Executed after the route's html has been compiled, but before the layout html has been compiled.

Props:
helpers
data
settings
request
query
errors
cssStack
headStack
customJsStack
layoutHtml
shortcodes
allRequests
Mutable:
errors
layoutHtml
cssStack
headStack
customJsStack

Elder.js uses this hook to process shortcodes. The vast majority of users won't need to use this hook, but if you were so inclined you could write your own shortcode parser or if you'd like to disable shortcodes completely, you can add 'elderProcessShortcodes' to hooks.disable in your elder.config.js file.

NOTE: Don't use this hook for anything besides shortcodes.

Advanced Hook: This hook is designed for plugins and power users who want to custom Elder.js beyond the common use cases.

Stable · Location: Page.ts

'stacks'

Executed just before processing all of the stacks into strings.

Props:
helpers
data
settings
request
query
errors
cssStack
headStack
beforeHydrateStack
hydrateStack
customJsStack
footerStack
Mutable:
errors
cssStack
headStack
beforeHydrateStack
hydrateStack
customJsStack
footerStack

Elder.js uses 'stacks' to manage it's string concatenation. If you are unfamiliar, stacks are basically an array of strings, with a priority, and some meta data. This hook let's you manipulate or view the stacks before they are written to the page and is designed for use by plugins.

This hook will mainly be used when you need to add arbitrary strings to the footer. In most cases, users should be using <svelte:head></svelte:head> to add content to the head.

  • headStack: Internally all content used in are added to the head stack. If you were looking to add ld+json to the page, you could do it here. If you're looking to write <title> tags, we recommend doing it within Svelte templates unless you are writing a plugin in which case you may want to also look at the 'head' hook.
  • cssStack: The 'cssStack' represents all of the css strings added by hooks and plugins. Plugins can add css here (such as critical path CSS), but we recommend users add them directly in Svelte files. Note: Do not wrap strings added to the stack in <style></style>.
  • beforeHydrateStack: default this stack includes a polyfill for intersection observer. This stack is not run unless there are Svelte components to be hydrated.
  • hydrateStack: the hydrateStack contains strings which represent all of the root svelte components which will be hydrated.
  • customJsStack: Used to add custom JS to the site. This is done after the Svelte components are written to the page.
  • footerStack: the footerStack which is an array of html or html friendly strings that will be written to the footer. This is generally the ideal place for plugins to add Analytics scripts as it fires after all other JS.

Advanced Hook: This hook is designed for plugins and power users who want to custom Elder.js beyond the common use cases.

Stable · Location: Page.ts

'head'

Executed just before writing the tag to the page.

Props:
helpers
data
settings
request
headString
query
errors
Mutable:
errors
headString

This hook's headSting represents everything that will be written to <head> tag.

There are many possible SEO uses to this hook, especially for plugins. That said, we recommend users who want to set common SEO elements such as tags <title> and meta descriptions programmatically to do it from within Svelte templates using the <svelte:head></svelte:head> tag. Chances are you won't need this field unless you're a power user and need access to the raw head.

Advanced Hook: This hook is designed for plugins and power users who want to custom Elder.js beyond the common use cases.

Stable · Location: Page.ts

'compileHtml'

This is where Elder.js merges the html from the Svelte layout with stacks and wraps it in an tag.

Props:
helpers
data
request
headString
footerString
layoutHtml
htmlString
Mutable:
errors
htmlString

This hook should only be used when you need to have full control over the <html> document. Make sure if you use this to add 'elderCompileHtml' to the 'hooks.disable' array in your elder.config.js or your template will be overwritten.

Advanced Hook: This hook is designed for plugins and power users who want to custom Elder.js beyond the common use cases.

Stable · Location: Page.ts

'html'

Executed when all of the html has been compiled.

Props:
helpers
data
settings
request
htmlString
query
errors
Mutable:
errors
htmlString

This hook receives the full html of the document. With great power comes great responsibility.

  • Can be used to compress the html/css/js.
  • Could be used to programmatically extract h2/h3 tags and build/inject a table of contents with something like Cheeriojs.
  • If you need to modify the final html output, here is where you can do it.
Stable · Location: Page.ts

'requestComplete'

This hook marks the end of the request lifecycle.

Props:
request
htmlString
query
settings
errors
timings
data
Mutable:
errors

This hook is triggered on an individual 'request object' completing whether Elder.js is being used in the "build" or a "server" mode.

  • Internally, Elder.js uses this hook to write html to the "public folder".
  • Useful for uploading static html to s3 or another source.
  • Could also be used to write the output of a route's "data" function file to help with client site routing if you were so inclined.
  • This hook may also be used by plugins to clean up any request specific 'state' they have stored.
  • By default Elder.js adds a hook here to all server requests that outputs how long the request took to generate. If you want to see detailed output from this hook set debug.speed = true in your config file.
Stable · Location: Page.ts

'error'

Executed only if the script has encountered errors and they are pushed to the errors array.

Props:
helpers
data
settings
request
query
errors
Mutable:

As the script encounters errors, they are collected and presented on this hook at the end of a request and the end of an entire build.

Stable · Location: Page.ts, build.ts

'buildComplete'

Executed after a build is complete

Props:
helpers
data
settings
timings
query
errors
routes
allRequests
Mutable:

Contains whether the build was successful. If not it contains errors for the entire build. Also includes average performance details, and a granular performance object. Could be used to fire off additional scripts such as generating a sitemap or copying asset files to the public folder.

Plugins: Because builds are split across processes, a plugin doesn't not have a shared memory space across all processes.

Stable · Location: build.ts

Hook Example 1: bootstrap

Here is the bootstrap hook from the hookInterface.ts file.

// From hookInterface.ts
{
  "hook": "bootstrap",
  "props": [
    "helpers",
    "data",
    "settings",
    "routes",
    "hooks",
    "query",
    "errors"
  ],
  "mutable": [
    "errors",
    "helpers",
    "data",
    "settings",
    "query"
  ],
  "context": "Routes, plugins, and hooks have been collected and validated.",
  "use": "<ul>\n    <li>Often used to populate the empty query object with a database or API connection as query is passed to the all() function which is used to generate request objects.</li>\n    <li>Internally used to automatically populate the helpers object with the helpers found in './src/helpers/index.js'.</li>\n    <li>Can be used to set information on the data object that is needed throughout the entire lifecycle. (sitewide settings)</li>\n    </ul>",
  "location": "Elder.ts",
  "experimental": false,
  "advanced": false
};

This hook is executed after Elder.js has bootstrapped itself and lets users run arbitrary functions at that point too.

Internally, Elder.js uses this hook to automatically any user defined helpers ./src/helpers/index.js to the helpers prop that is available on other hooks, in Svelte templates, and data functions.

Fetching External Data

// ./src/hooks.js
const fetch = require('node-fetch');
module.exports = [
  {
    hook: 'bootstrap',
    name: 'addExternalData',
    description: 'Adds arbitrary external data to the data object available in all hooks and routes.',
    run: async ({ settings, data }) => {
      const externalData = await fetch('https://yourapi.here').then((res) => res.json());
      return {
        data: {
          ...data,
          externalData, // this data is now available in the `all` and `data` functions of your `/routes/routeName/route.js`.
        },
      };
    },
  },
};

Adding DB to the query Object

Here is what a simple hook defined in your ./src/hook.js file might look like if you wanted to add a database connection to the query object which is available every time a hook is called:

// ./src/hooks.js
const db = require('../db');
module.exports = [
  {
    hook: 'bootstrap',
    name: 'addDbToQuery',
    description: 'Adds our db object to the query object',
    priority: 99,  // higher is more important. Since we want to be able to use the DB in other hooks that may be on the bootstrap hook, higher is better.
    run: async ({ query }) => {
      return {
        query: { ...query, db },
      };
    },
  },
};

In plain english:

addDbToQuery runs on the bootstrap hook and adds the db object as a key to the query object.

If you wanted to initialize a database connection and make it available on all hooks and in data functions, this is how you'd do it.

Where to Organize Hooks

USER HOOKS:

We recommend you define all of your hooks in your ./src/hooks.js file.

It is also recommended that you organize them to be sequential with hook execution as shown above.

If you need to limit a function to only run on a specific route, you can do so by using request.route === 'routeName'.

Note: If you're finding your ./src/hooks.js is becoming too big, resist the urge as long as possible to split it into sub files. We've found that even with 20+ hooks, as long as they are organized sequential to match hook execution, things stay maintainable.

SYSTEM HOOKS:

Under the hood, all of the hooks Elder.js runs are defined in the @elderjs/elderjs ./src/hooks/index.ts.

They can be disabled by adding the hook name to the hooks.disable array in your elder.config.js.

  // elder.config.js
  ...
  hooks: {
    disable: ['elderWriteHtmlFileToPublic'], // this is used to disable internal hooks. Adding this would disabled writing your files on build.
  }
  ...

Plugins

Plugins are prepackaged hooks and/or routes that can be used to add additional functionality to an Elder.js site.

Plugins also have the added bonus of having their own isolated closure scope where they can store data between hooks invocations.

To use a plugin, it must be registered in your elder.config.js and can be loaded from ./src/plugins/${pluginName}/index.js or from the entry point to an npm package ./node_modules/${pluginName}/

Official Plugins:

  • Images Easily add and manage responsive images with your Elder.js website.
  • Markdown An extensible markdown parser for Elder.js powered by remark. Take a folder of markdown and automatically populate your route's data object.
  • Critical Path CSS Quickly and easily generate and include critical path css for your Elder.js website.
  • Sitemap Automatically generate the latest sitemap for your Elder.js website on build.
  • Browser Reload Reload the browser when your Elder.js server restarts.
  • References Easily add wikipedia style references to your content with ref and referenceList shortcodes.

Other Plugin Ideas:

  • Want to upload your statically generated files to S3? Perfect use case for a plugin. (See plugin example below)
  • RSS feed plugin

Writing Your Own Plugin

If you're looking to write your own plugin for Elder.js, we've setup an easy template to clone.

Here is the command you can use to clone it locally without all of the git history using degit.

npx degit Elderjs/plugin-template elderjs-plugin
cd elderjs-plugin

Plugin Example 1: S3 Upload

Here is what a plugin looks like:

const plugin: PluginOptions = {
  name: "elder-plugin-upload-s3",
  description: "Uploads html and/or data.json file to s3",
  init: (plugin) => {
    // console.log(plugin); => returns this plugin object.
    plugin.data = { test: true };
    // NOTE! any data added in any hook or in the init function will
    // be persisted for the entire lifecycle of elder.js
    // this means the closure persists between server.js loads AND between pages during build.
    // Temporary data can be stored on this object,
    // but it is up to the plugin to clean up after itself and manage its own state.
    return plugin;
  },
  hooks: [
    {
      hook: "requestComplete",
      name: "uploadDataObjectToS3",
      description: "Uploads a data.json file to s3",
      priority: 1, // we want it to be last
      run: async ({ data, settings, request, plugin }) => {
        // console.log(plugin.test) => true
        if (settings.build === true && settings.deploy === true) {
          if (plugin.config.dataBucket && plugin.config.dataBucket.length > 0) {
            let dest = `${request.permalink.replace(/^\/+/, "")}data.json`;
            if (plugin.config.deployId) {
              dest = `${plugin.config.deployId}/${dest}`;
            }
            await s3Helper.uploadToS3(
              dest,
              JSON.stringify(data),
              "application/json",
              plugin.config.dataBucket
            );
          }
        }
      },
    },
    {
      hook: "requestComplete",
      name: "uploadHtmlToS3",
      description: "Uploads a html file to s3 bucket.",
      priority: 1, // we want it to be last
      run: async ({ settings, request, html, plugin }) => {
        if (settings.build === true && settings.deploy === true) {
          if (plugin.config.dataBucket && plugin.config.htmlBucket.length > 0) {
            let dest = `${request.permalink.replace(/^\/+/, "")}index.html`;
            if (plugin.config.deployId) {
              dest = `${plugin.config.deployId}/${dest}`;
            }

            await s3Helper.uploadToS3(
              dest,
              html,
              "text/html",
              plugin.config.htmlBucket
            );
          }
        }
      },
    },
  ],
  config: {
    dataBucket: process.env.AWS_S3_DATA_BUCKET || "",
    htmlBucket: process.env.AWS_S3_BUCKET || "",
    deployId: process.env.DEPLOY_ID || false,
  },
};

This plugin registers function executions on two hooks, the dataComplete hook and the requestComplete hook. In each, it uploads the data or html to the s3 bucket specified in the user's elder.config.js.

Svelte

Within Elder.js, there is a subtle distinction between how different Svelte files are compiled.

  • Svelte Components live in your ./src/components/ folder and are called from within Svelte Templates and Svelte Layouts. (eg: ./src/components/Widget/Widget.svelte)
  • Svelte Templates are defined for each route (eg: ./src/routes/blog/Blog.svelte) and are only rendered on the server because they receive props of data, helpers, request, and settings.
  • Svelte Layouts live in your ./src/layouts/ folder and are only rendered on the server because they receive props of data, helpers, request, settings, and templateHtml.

While this may seem complex, the reasoning for Svelte Templates and Svelte Layouts only being server rendered is because they receive sensitive props that may contain data you don't want written in your html. (database credentials, env variables, auth keys, etc)

On a practical level, most of your Svelte files will live in your ./src/components/ folder and you can hydrate them from within Svelte Templates or Svelte Layouts as defined below.

Partial Hydration

Elder.js give you fine grained control over what parts of your site are "hydrated" on the client and which aren't.

If you aren't sure what should be hydrated and what shouldn't, the general rule of thumb is that if a component needs to be interactive on the client, you need to hydrate it.

To hydrate a component, simply use the following markup in either your Svelte Template or Svelte Layout files. (eg: ./src/routes/blog/Blog.svelte or ./src/layouts/Layout.svelte)

// within any Svelte template
 <MyComponent hydrate-client={{somethingCool: true}} />

In this example, the component MyComponent will receive the props of somethingCool = true which you'd access from within the component like so:

<!-- ./src/components/MyComponent.svelte -->
<script>
  export let somethingCool;
</script>

Understanding Partial hydration

In the above example, what will happen is that in your HTML, you'll see that Elder.js has mounted a new root component and set the initial props to {somethingCool: true}.

This means that once the user visits the page generated by Elder.js, Svelte will make that specific component interactive.

There are two differences between partial hydration and the way most frameworks handle hydration:

  1. With partial hydration, you're only sending the data needed to make the respective components interactive instead of data for the whole page.
  2. With partial hydration, you end up with multiple root components instead of one main component root. (Don't worry, you can still use Svelte stores to allow them to easily share state across them.)

The end result is generally smaller bundle/page sizes and less work for the main thread because we're only hydrating what is needed by the client instead of all of the data to build the page.

Note: All props needed by the Svelte component must be included in hydrate-client={{}} and should be JSON.stringify() friendly. This means no functions, cyclical references, etc.

// Doesn't work
<Component {ssrProp} hydrate-client={{ clientProp }} />

// Works
<Component hydrate-client={{ ssrProp, clientProp }} />

The environment variable 'process.env.componentType' will return browser or server depending on where the component is being rendered. process.env.componentType === 'server' is the correct way to check if a component is rendering on the server.

Hydration Options:

To give you fine grained control over how a Svelte component behaves when it is mounted, the following hydrate-options can be defined:

  • hydrate-options={{ loading: 'lazy' }} This is the default config, uses intersection observer to 'lazily' mount the Svelte component.
  • hydrate-options={{ loading: 'eager' }} This would cause the component to be hydrated in a blocking manner as soon as the js is rendered.
  • hydrate-options={{ loading: 'none' }} This allows you to add the HTML from a Svelte component, but not to hydrate it on the client. (only really useful with helpers.inlineSvelteComponent and possibly advanced shortcode usages.)
  • hydrate-options={{ preload: true }} This adds a preload to the head stack as outlined above... could be preloaded without forcing blocking.
  • hydrate-options={{ preload: true, loading: 'eager' }} This would preload and be blocking.
  • hydrate-options={{ rootMargin: '500px', threshold: 0 }} This would adjust the root margin of the intersection observer. Only usable with loading: 'lazy'

How partial hydration works under the covers.

On the homepage of elderguide.com, we use the following code to hydrate the autocomplete component:

// within elderguide.com’s Home.svelte
<HomeAutoComplete hydrate-client={{ nh_count: data.nh_count }} />

You can do the same for a component without any props by using:

  <NoPropsHere hydrate-client={{}} />

At a high level, what is happening is that when the Svelte template components are compiled on the server, we've included a preprocessor that causes the Svelte compiler to instead render a div with a few specific elements and the prop passed into hydrate-client is simply JSON.stringified.

Later when we go to render these templates, we look for the removed components, generate the server rendered version and include the client component in the generated JS with the props that were given in hydrate-client.

Security Note: Whatever you pass to hydrate-client will get written to the HTML shipped to the browser via devalue. There are XSS and security considerations of passing data to the client.

If you are curious, the files to look at are: partialHydration.ts and svelteComponent.ts.

The important thing to note is that still use Svelte variables in hydrate-client as long as they can be processed by JSON.stringify.

Slots

A common pitfall is to try and use slots while hydrating a component. This won't work because Svelte's mount code doesn't support slots during mounting.

To get around this, create a parent component without slots to hydrate, then import the component that uses slots within that file.

Remember, partial hydration is just a wrapper around Svelte's mount code.

Shortcodes

Whether your content lives in markdown files, on Prismic, Contentful, WordPress, Strapi, your own CMS, at some point you or someone who is managing the content will want to add some 'functionality' to this otherwise static content.

These functionalities come in a few flavors:

  • Embedding an arbitrary Svelte component directly within the content.
  • Adding custom HTML to style/wrap content or achieve design goals.
  • Updating this otherwise static content automatically when a dynamic datapoint changes.
  • Creating a placeholder so that data can be fetched from an external service such as twitter/instagram and still be available via SSR. (Example: Server rendering your latest tweets.)

Adding this type of functionality is a nightmare and is a huge source of content debt and tech debt for SEO sites.

To make these situations more approachable, Elder.js offers shortcodes.

Overview

If you aren't familiar with shortcodes, they are just strings that can wrap content or have their own attributes:

  • Self Closing: {{shortcode attribute="" /}}
  • Wrapping: {{shortcode attribute=""}}wraps{{/shortcode}}

NOTE: The {{ and }} brackets vary from system to system and can be configured in your elder.config.js. However the / prefix for the closing bracket is not configurable. You may therefore need to translate shortcodes written in another format into this format expected by Elder.js, with a simple string replace strategy.

In Elder.js, shortcodes are added by defining them in a project's ./src/shortcode.js or via plugins.

Use Cases For Shortcodes:

Adding a Component Directly in Static Content

Imagine you want to empower the content team to embed a Svelte widget component anywhere within their content.

Out of the box, Elder.js adds a shortcode for this.

Simply tell them to add {{svelteComponent name="widget" props="{blue: true}" /}} to their markup and Elder.js will hydrate and mount that component.

Adding custom HTML to style/wrap content or achieve design goals.

Imagine you're at work and the design team asks you to wrap small pieces of content with a wrapper of <div class="bg-gray mb-1 p-2"> to 100 pieces of content... but having seen this situation before, you realize that within 3 weeks it will probably need to be: <div class="bg-gray mb-2 p-3 somethingelse">.

So to future proof this code change, you introduce a box shortcode and you wrap your content in it like so:

{{box type="gray"}}
  Your content here
{{/box}}

Then in your shortcodes.js you add the following:

module.exports = [
  // ./src/shortcodes.js
  {
    shortcode: "box",
    run: async ({ props, content }) => {
      if (props.type === "gray") {
        return `<div class="bg-gray mb-1 p-2">${content}</div>`;
      }
      // note that this shortcode returns a string.
      return content;
    },
  },
];

Updating Datapoints in Static Content

The most common type of content debt is data sensitive content debt.

This is where your otherwise static content needs to have some arbitrary data point updated within it.

This is a common use case for us here at ElderGuide.com.

On our content, we often need to write things like: The US has {{numberOfNursingHomes /}} nursing homes nationwide.

Using shortcodes to make sure {{numberOfNursingHomes /}}is always up-to-date future proofs us from having this content debt.

Since our data comes from a database and all implementations will differ a bit, we'll steal the example from the Elder.js Template instead.

Imagine you need to always show the latest {{numberOfPages /}} on your site but you don't want to update {{numberOfPages /}} each time you publish a new blog post.

Here is how you'd create a shortcode using Elder.js internals to do just that:

module.exports = [
  // ./src/shortcodes.js
    {
    shortcode: 'numberOfPages',
    run: async ({ allRequests }) => {
      // allRequests represents 'request' objects for all of the pages of our site, if we know the length of that we know the length of our site.
      return allRequests.length,
    },
  },
]

Now, you can update your site to use {{numberOfPages /}} and any time the page count changes, so will the placeholder.

Advanced: A Placeholder For External Data/Content

One of the most powerful usecases for shortcodes is to use them as a placeholder for external data fetching.

Imagine you want the ability to display your latest tweet in a specific spot across multiple pages.

You setup the {{latestTweet /}} shortcode and instead of just returning the latest tweet, we also want to add some css and js to the page as well.

Here is the full power of how you'd implement this:

// import 'node-fetch', 'axios'
// ./src/shortcodes.js
module.exports = [
  {
    shortcode: "latestTweet",
    run: async () => {
      // const latestTweet = await fetch(fromTwitterApi);
      // fetching the data is up to you...

      // while shortcodes often return strings, they can also return objects like so:
      return {
        // this is what the shortcode is replaced with. You CAN return an empty string.
        html: `<div class="latest-tweet">${latestTweet}</div>`,

        // You can add css here and it will get written to the head.
        css:
          ".box{border:1px solid red; padding: 1rem; margin: 1rem 0;} .box.yellow {background: lightyellow;}",

        // Javascript that is added to the footer via the customJsStack.
        js: "<script>var test = true;</script>",

        // Arbitrary HTML that is added to the head via the headStack
        head: '<meta test="true"/>',
      };
    },
  },
];

Shortcode Spec:

Shortcodes are defined by users by adding them to the array in their ./src/shortcodes.js.

In the spec below, there is a simple example that returns a string and a full example that returns an object.

// ./src/shortcodes.js

module.exports = [
  {
    shortcode: "simple", // the shortcode name: results in {{simple /}}
    run: async ({
      props, // the attributes of the shortcode
      content, // the content wrapped by the shortcode.
    }) => {
      return '<div class="simple">simple</div>';
    },
  },
  {
    shortcode: "returnsObject", // this is the shortcode name.
    run: async ({
      props, // the attributes defined on the shortcode.
      content, // the content wrapped by the shortcode.
      request, // the 'request' object for the page requested.
      query, // the 'query' object
      helpers, // the 'helpers' object with any user helpers and Elder.js helpers
      settings, // settings
      allRequests, // all of the 'request' objects Elder.js has.
    }) => {
      // while shortcodes often return strings, they can also return objects like so:
      return {
        html: "", // this is what the shortcode is replaced with. You CAN return an empty string.
        css: "", // You can add css here and it will get written to the head.
        js: "", // Javascript that is added to the footer via the customJsStack.
        head: "", // Arbitrary HTML that is added to the head via the headStack
      };
    },
  },
];

Using Shortcodes from Within Svelte

Sometimes you'll want to use a shortcode from within your Svelte route files or layouts.

To do this, there is a helper function to inline shortcodes in a "Svelte Friendly" way:

<!-- Layout.svelte or RouteName.svelte -->
<script>
export let helpers;
</script>
{@html helpers.shortcode({ name: 'shortcodeName', props: { background: "blue" }, content: "Inner content" })}

This results in {{shortcodeName background="blue"}}Inner content{{/shortcodeName}} being output in the HTML and picked up by the shortcode parser during the page generation process.

CSS

Elder.js has 4 different modes of handling CSS each of which can be set in your elder.config.js by settings the desired value on the css key.

  1. file: (default) All of the CSS from Svelte components and imported into Svelte components is written to a file and included in the head.
  2. lazy: All of the CSS from Svelte components and imported into Svelte components is written to a file and included in the head but is lazily loaded. (Designed for use with the Elder.js critical path plugin.)
  3. inline One where all CSS a component depends on will be added to the head. This is how Elder.js started so this is the default. This is a great option for serverless rendering.
  4. none no css handling.

Source maps are included where available when process.env.NODE_ENV !== "PRODUCTION".

Including External CSS

The best practice for including external CSS is to simply import it:

// any svelte file.
import "./path/to/css/file.css";

If the CSS isn't appearing for some reason, try rerunning Rollup.

Elder.js In Detail

Data Flow

Here is a detailed overview of how data flows through an Elder.js application from 'bootstrap' all the way to a generated HTML page.

1. Everything starts in a site's route.js files

Below is the example route.js file we'll be following the flow of.

// `/routes/blog/route.js` <-- NOTE: 'blog' is the route name.
module.exports = {
  all: async ({ query, settings data, helpers }) => {
    // await query.db(`your implementation here`) or await query.api(`get data`);
    // something that returns an array of the minimum required data for the route.
    return [{slug: 'why-kitten-rock'}];
  },
  permalink: ({ request, settings }) => {
    return `/blog/${request.slug}/`;
  },
  data: async ({request, query, settings, helpers, data }) =>{
    // we'll look at this function below.
  }

  // template: 'Blog.svelte' is assumed if not defined. (note: capitalized first letter.)
  // layout: 'Layout.svelte' is assumed if not defined.
}

2. Elder.js Bootstrap Itself

During this process, Elder.js validates all of the routes, plugins, hooks. It then runs the 'bootstrap' hook.

Finally, the all function for each route is executed.

Together, the aggregate result of each route's all function is referred to as allRequests.

3. The allRequests Hook is Run

This allows users to modify the allRequests array. If you modify or add to this array of objects, make sure each result has a 'request.route' key.

4. Full 'request' Objects are Built

Once Elder.js has a full list of requests, it then builds permalinks and full 'request' objects that will be consumed by hooks, data functions, Svelte templates, and Svelte layouts.

The full request object will look something like so (truncated for clarity):

request = {
  slug: `why-kittens-rock`,
  // ... any other keys from the `request` object returned from the `all` function.

  // below is then added by Elder.js
  permalink: "/blog/why-kittens-rock",
  route: "blog",
  type: "build", // server or build.
};

It is important to note that all of the params of the 'request' objects returned by the all function will be present in the 'request' object even though our example only uses slug.

5. Hooks are Executed Until the data function is Executed

The data flows through all of the hooks until it reaches a route's data function.

How you modify the data in your data function is up to you. Anything you can do in Node.js, you can do here.

module.exports = async ({ query, settings, request, data }) => {
  // do magic to get data from your data store.
  // const yourData = await query.db(`SELECT * FROM city WHERE slug = $1`, [request.slug])
  // or
  // const yourData = await query.api.get(`https://yourdata.com/api/city/${request.slug}`)...
  const yourData = {
    sweet: "Golden Metal",
  };
  return yourData;
};

6. The 'data' hook is Executed

The data hook is generally used by plugins to modify data for a route.

If for some reason you have an empty data object, check that your plugins and hooks aren't returning just their data, instead of using a pattern like so:

return {
  data: {
    ...data, // this is from the parameter of the hook function.
    ...additionalData, // this data from the hook or plugin.
  },
};

You can debug this by setting debug.hooks: true in your elder.config.js.

7. The data Object is passed to the Svelte template

In this example, ./src/routes/blog/Blog.svelte may look like this:

<script>
  export let data; // here is the 'data' object we've been following.
  export let settings; // Elder.js settings
  export let helpers; // Elder.js helpers and user helpers.
  export let request; // 'request' object from above. ....
</script>

8. The HTML returned by Blog.svelte is passed into Layout.svelte

Svelte layouts receive the same props as the template file but also include a templateHtml prop which would be the html from Blog.svelte in this example.

9. Page Generation Completes

All further hooks are run until the 'request' has been completed.

This includes user hooks, system hooks, and plugin hooks.

Specifications and Config

Below are details on common specifications and config requirements.

Config: elder.config.js

By default, Elder.js looks for an elder.config.js file in your project root and will import any settings there and merge them with the default.

Below is what the default configuration file looks like. This is automatically generated if an elder.config.js file is missing.

module.exports = {
  origin: "", // The domain your site is hosted on. https://yourdomain.com.
  rootDir: "process.cwd()", // Here your package.json lives.
  distDir: "public", // Where should files be written? This represents the "root" of your site and where your html will be built.
  srcDir: "src", // Where Elder.js should find it's expected file structure. If you are using a build step such as typescript on your project you may need to edit this. 
  debug: {
    stacks: false, // Outputs details of stack processing in the console.
    hooks: false, // Output details of hook execution in the console.
    shortcodes: false, // Output details of shortcode execution in the console.
    performance: false, // Outputs a detailed speed report on each pageload.
    build: false, // Displays detailed build information for each worker.
    automagic: false, // Displays settings or actions that are automagically done to help with debugging.
  }
  hooks: {
    disable: [], // This is an array of hooks to be excluded from execution. To be clear this isn't the "hook" name found in the "hookInterface.ts" file but instead the name of the system user plugin or route hook that is defined.  For instance if you wanted to by name to prevent the system hook that writes html to the public folder during builds from being run you would add "internalWriteFile" to this array.
  }
  server: {
    prefix: "", // If Elder.js should serve all pages with a prefix.
  }
  build: {
    numberOfWorkers: -1, // This controls the number of worker processes spun up during build. It accepts negative numbers to represent the number of cpus minus the given number. Or the total number of processes to spin up. 
    shuffleRequests: false, // If you have some pages that take longer to generate than others you may want to shuffle your requests so they are spread out more evenly across processes when building.
  }
  shortcodes: {
    openPattern: "{{", // Opening pattern for identifying shortcodes in html output.
    closePattern: "}}", // closing pattern for identifying shortcodes in html output.
  }
  plugins: {}, // Used to define Elder.js plugins.
  legacy: false, // EXPERIMENTAL: This implementation will not work in all scenarios may change in the future or be dropped completely... but Elder.js will attempt to add an IE11/nomodule friendly iife bundle for each component on production rollup builds. Please note currently shared stores do not work but see this issue: https://github.com/Elderjs/elderjs/issues/44#issue-709580756 and you may need to bring your own polyfills.
  css: "file", // How should css found in svelte files be handled? 'inline' emits styles into the head. 'file' adds a file include into the head. 'none' doesn't do anything with the styles.
}

Elder.js Expected file structure

Elder.js expects a specific file structure outlined below. This is why we recommend you start with the Elder.js template.

You can configure or rename your src/build folders in your elder.config.js.

Project Root
| elder.config.js
| package.json
| rollup.config.js
| ... (other common stuff, .gitignore, svelte.config.js... etc)
| -- src
| -- | -- build.js
| -- | -- server.js
| -- | -- hooks.js
| -- | -- shortcodes.js
| -- helpers
| -- | -- index.js
| -- | -- ...
| -- layouts
| -- | -- Layout.svelte
| -- routes
| -- | -- [route] ('blog' in this example)
| -- | -- | -- Blog.svelte
| -- | -- | -- route.js
| -- plugins
| -- | -- [plugin] ('elderjs-plugin-markdown' for example)
| -- | -- | -- index.js
| -- components
| -- | -- [component] ('Contact' in this example)
| -- | -- | -- Contact.svelte

(optional/recommended)
| -- assets
| -- | -- files to be copied to your public folder.

Hook Specification

Hooks are the core of how to make site level customizations. Below is the default spec for a hook.


module.exports = {
  hook: "", // The hook the defined "run" function should be executed on.
  name: "", // A user friendly name of the function to be run.
  description: "", // A description of what the function does.
  priority: 50, // The priority level a hook should run at. The highest priority is 100 and 1 is the lowest priority.
  run: ()=> {}, // The function to be run on the hook.
  $meta: {
    type: "", // What type of hook this is. Defined by Elder.js for debugging.
    addedBy: "", // Where the hook was added from. Defined by Elder.js for debugging.
  }
}

Plugin Specification

Plugins are a bundle of hooks with their own closure scope based on the object that is returned by the init() function. This means that all hooks and shortcodes receive the plugin definition returned by the init() function and are able to store properties in that scope throughout the hook lifecycle. Below is the default specification for a plugin.


module.exports = {
  name: "", // The name of the plugin.
  description: "", // A description of the plugin.
  init: ()=> {}, // A sync function that handles the plugin init. Receives plugin definition. plugin.settings contains Elder.js config. plugin.config contains plugin config
  routes: {}, // (optional) Any routes the plugin is adding.
  hooks: [], // An array of hooks.
  config: {}, // (optional) An object of default configs. These will be used when none are set in their elder.config.js.
  shortcodes: [], // Array of shortcodes
}

Route Specification

Routes can be defined by plugins or by including a ./src/[routeName]/route.js file.


module.exports = {
  template: "", // Svelte file for your route. Defaults to RouteName.svelte if not defined.
  all: ()=> {}, // A sync/async function that returns an array of all of the 'request objects' for this route.
  permalink: ()=> {}, // Sync function that turns request objects from the all() function into permalinks which are relative to the site root
  data: ()=> {}, // Async/sync function that returns a JS object. Can also be a plain JS object.
}

Shortcode Specification

Shortcodes are a great way to future proof your content. Below is the default shortcode specification. These should be defined in the array exported by your ./src/shortcodes.js


module.exports = [{
  shortcode: "", // The 'name' of the shortcode. {{name /}}
  run: ()=> {}, // A sync/async function that returns the html css js and head to be added to the html.
}]

Requirements for name and description fields

In various places such as on hooks, plugins, and stacks, you'll see that Elder.js requires name and description fields.

While this requirement may seem like an added development burden initially, it makes it extremely easy for you to publicly share your hooks or bundle them into plugins in a way others can use. (Plus your future self will thank you when you need to modify your code a year from now.)

These fields are also used to generate the pretty printouts of how long each hook is adding to your page generation time that you see when you enable debug.performance = true in your elder.config.js.

Elder.js Performance Debug True

As a team, we've found build times to be especially important when building 10k+ page sites as 100ms adds 16+ minutes to your build time. What gets measured gets managed... and we know faster deploys leads to deploying more often.

Stacks: Predictable String Concatenation

In a few places, you may see that Elder.js is using 'stacks.' These are just our internal way of processing strings that need to be concatenated.

Here are the type defs:

export type StackItem = {
  source: string;
  string: string;
  priority: number;
  name: string;
};

export type Stack = Array<StackItem>;

In short, a stack is an array of objects that are concatenated together in order. Think of it like a queue. An item with a priority of 1 is the lowest priority. An item with a priority of 100 is the highest.

Hooks can add items to the stack, when the stack is processed, it is sorted in order of priority and then all strings are concatenated.

Automatic Behavior

Elder.js does quite a bit of automatic behavior much of which is based on using the standard filesystem.

Instead of burying this magic, things that happen automagically are logged to the console unless you set debug.automagic = false in your elder.config.js file. That said, here are some of the things that happen automatically:

  • All exports from ./src/helpers/index.js are imported and added to the helpers object which is available in the data functions and all hooks.
  • All hooks in your ./src/hooks.js file, ./routes/[routeName]/route.js files, and plugins are imported and validated.
  • All optional variables in your routes file will be set automatically if Elder.js detects that the files needed are present.

Default Helpers

By default, Elder.js adds a few items to the helpers object that is available in hooks, Svelte templates/layouts, and data functions.

  • permalink: A permalink resolver: helpers.permalink[routeName]({requestObject}). Simply pass in a request object and it'll resolve the permalink. It is often used like so helpers.permalink.blog({slug: 'kittens-rock'}).
  • shortcode: A more comfortable way to use shortcodes within Svelte files. Common usage within a .svelte file may look like : { @html helpers.shortcode({name: 'box', props: {class: "yellow"}, content: "content string here" }) }. If you are using the default Elder.js shortcode brackets, this would output {{box class='yellow'}}content string here{{/box}} which would be parsed like any other shortcode.
  • inlineSvelteComponent: This helper is mainly useful when needing to add a Svelte component to your html via, hooks, plugins, or custom shortcodes. All of the options available when hydrating a component are available with this helper, it simply outputs the required hydration html so that Elder.js picks it up and hydrates the client. helpers.inlineSvelteComponent({name: 'Foo', props: { anything: true }, options: { preload: true, eager: true }})

Elder.js Exports:

server

Elder.js was built as a static site generator, but it offers a built-in server that is pretty snappy past the initial bootstrap phase.

This can be used to power server rendered (SSR) apps or used to preview the output of your static site without having to build.

You can see how this functionality is utilized in the elderjs-template.

build

The build function exported from Elder.js uses Node's cluster module to use multiple processes to build your site.

In short, here is how build works at a high level:

The main process runs through bootstrap and collects allRequests. It then spins up workers and divides the requests evenly across these workers. It then waits for the workers to complete, runs any hooks for after build and completes.

There are two notable config options: numberOfWorkers and shuffleRequests which you can read about in the config section.

getRollupConfig

A function that generates all of the Elder.js required rollup output.

See the clonable template for the minimum viable rollup config.

Replacements

If you need to add values to be replaced during bundling, you can do so like this:

const { getRollupConfig } = require("@elderjs/elderjs");
const svelteConfig = require("./svelte.config");
module.exports = [
  ...getRollupConfig({
    svelteConfig,
    rollupConfig: {
      replacements: { "http://localhost:4020": "https://production.com" },
      // object keys are replaced by values.
    },
  }),
];

getElderConfig

A helper function that returns the user's elder.config.js with defaults added in where they aren't defined.

Using typescript/postcss/scss or other Preprocessors

Elder.js uses the svelte-preprocess package to enable the use of typescript, scss, postcss or any other preprocessor that the svelte-preprocess package supports. To enable a preprocessor, you need to first install the dependecies for it, for example, to enable typescript and sass, you need to install the following dependecies

npm install -D typescript node-sass

For more info about what dependecies you need to install to enable the preprocessor of your choice, you can look at the installation guide of svelte-preprocess

Then you can start using them in your svelte file by using lang="ts" or lang="scss" in your script and style block respectively. To change the settings of your preprocessor, open the svelte.config.js file and configure it using the object passed to sveltePreprocess like this

const sveltePreprocess = require("svelte-preprocess");

module.exports = {
  preprocess: [
    sveltePreprocess({
      postcss: {
        plugins: [require("autoprefixer")],
      },
    }),
  ],
};

Go through the svelte-preprocess docs to learn about all the supported features.

Extra setup for typescript

You should be able to use typescript in svelte files by following the instructions above but for customizing your typescript config, we recommend you rename the sample.tsconfig.json in the default template to tsconfig.json and use that as your typescript config. You can also extend [svelte/tsconfig] (https://www.npmjs.com/package/@tsconfig/svelte) which is the recommended config by the Svelte team. To run typescript checks as part of your linting process or CI/CD step, install the svelte-check package and add a script to your package.json to run this tool.

"type-check": "svelte-check"

Typescript support for non-svelte files is coming soon, track the progress here

FAQ

How can I disable a hook?

You can disable core Elder.js hooks, just like any other hook. Simply add the hook name to the hooks.disable array in your elder.config.js.

A full list of all of the hooks can be found in the hooks.ts.

For instance, if you wanted to disable the intersection observer, simply make sure your hooks.disable array includes elderAddDefaultIntersectionObserver as shown below:

// elder.config.js, truncated

hooks: {
  disable: ['elderAddDefaultIntersectionObserver'],
},

Caching Client Assets

By default Elder.js emits hashed files for assets under its control into the ./${distDir}/_elderjs/ folder.

This allows for aggressive caching of client side assets such as cache-control public, max-age=31536000, immutable.

How can I copy files to public?

The template project has a hook that copies your ./assets/ folder to the distDir location defined in your elder.config.js.

Why can't I use Svelte templates for data fetching/manipulation?

If you’re familiar with Sapper, you may be accustomed to storing your data fetching and manipulation logic in your Svelte files.

Initially, when designing Elder.js, we experimented with this approach but found that a separate data function, which returned a single data object ready to be consumed by your svelte template was a cleaner separation of concerns.

data functions for fetching, manipulating, and returning data. Svelte for handling data presentation, interactivity, and reactivity.

Note: If you really want to do data fetching and manipulation in your templates, nothing is stopping you.

How Does Elder.js Differ From Sapper?

Sapper is a full stack Svelte framework that does server side rendering and supports static exporting of your site.

While we found Sapper to be a nice solution for small sites, when we tried to use it to build out elderguide.com, we hit some major roadblocks:

  1. V1 of our internal test site had about ~500 pages. Sapper was talking about 15 minutes to build our site and we had worked hard to improve that speed. Since we knew our site would have over 100k pages in the coming years, we knew we needed a different solution.
  2. Sapper uses a file encoded routing system. This means that /blog/[id]/ becomes /blog/:id/. While this type of routing is common in JS frameworks, there are several SEO reasons you may want content of different types to live within the same subfolder. (In our specific use case, we wanted /senior-living/[content]/, /senior-living/[facilities]/, and /senior-living/[experts]/ to all live under /senior-living/. When you're planning on building a 100k page site over 5 years, early decisions matter. Especially for SEO as each permalink change adds a bit of 'drag.')
  3. We found Sapper’s data flow hard to reason about for data driven and complex sites.

For data fetching, Sapper uses a preload function to fetch a page’s initial props and build the page. This is done to enable client side routing and full client side hydration as this preload function is designed to run on both the client and the server.

This is a pretty cool feature for Apps but for a static SEO focused site, it was overkill and cause huge problems with our dataflow when paired with the chosen routing system.

Elder.js solves these roadblocks.

  1. Build times on our ~20k page site are routinely less than 10 minutes on a modest VM. In our analysis, over 55% of our build time is spent waiting on the database server so you may see much faster builds. :)
  2. You can setup the routes however you need. The only requirement is that you don’t have two routes with the same url. :) (We’ll tell you if it happens)
  3. Every route defined has a single data function where you can do your database queries, read markdown files, and do whatever magic you need to prepare your data for display.

Adding Database Access

If your project is going to be querying its data from a database, we recommend using the bootstrap hook and adding a connection to your database on the “query” property. See this hook example above.

Customizing the HTML 'shell'

In certain cases, you may want to customize the shell that Elder.js writes the templateHtml and layoutHtml to.

Often a quick regex on the html hook is enough, but if you'd like complete control, you can overwrite how Elder.js compiles the html by:

  1. Add elderCompileHtml to the hooks.disable array in your elder.config.js.
  2. Register a new function on the compileHtml hook and implement your desired functionality. (Look for elderCompileHtml in @elderjs/elderjs's hooks.ts file.)

Upgrading to v1.0.0:

Below are the breaking changes between v1 and earlier versions:

  1. link helper is removed from templates. You can access it at helpers.permalinks the same way as before so link.blog({slug: "foo"}) becomes helpers.permalinks.blog({slug: "foo"})
  2. routeHtml or routeHTML in the Layout.svelte in the Elder.js template has been changed to templateHtml. If you're getting no output from your templates, this is your issue. Rename this variable and things should work again.
  3. The order of stacks and hook priorities have been reversed. This may cause hooks to run out of order. Please look at your hooks. Before this update, hooks with a priority of 1 were the highest priority, now they are the lowest.
  4. process.browser replacements in rollup.config are now 'process.env.componentType' which will return browser or server. Remember process.env variables are strings. so process.env.componentType === 'server' is the correct way to check if a component is rendering on the server.
  5. There was a major rework to the elder.config.js. If you defined anything in the elder.config.js under the locations key, you'll need to rework those into the distDir, srcDir, and rootDir.
  6. siteUrl in elder.config.js was changed to origin and you'll get an error if you don't set it.
  7. Remove automatic checking for a tsconfig. If you want to use typescript, please set your srcDir to the build folder found in your tsconfig.

File / Hook Changes:

  1. The ./src/assets/ folder has been moved to ./assets/ (project root).
  2. You'll need to update your copyAssetsToPublic hook as shown below.
  3. You'll need to update your rollup.config.js to be updated as shown below.
  // hooks.js
  // replace your old copyAssetsToPublic
  {
    hook: 'bootstrap',
    name: 'copyAssetsToPublic',
    description:
      'Copies ./assets/ to the "distDir" defined in the elder.config.js. This function helps support the live reload process.',
    run: ({ settings }) => {
      // note that this function doesn't manipulate any props or return anything.
      // It is just executed on the 'bootstrap' hook which runs once when Elder.js is starting.

      // copy assets folder to public destination
      glob.sync(path.resolve(settings.rootDir, './assets/**/*')).forEach((file) => {
        const parsed = path.parse(file);
        // Only write the file/folder structure if it has an extension
        if (parsed.ext && parsed.ext.length > 0) {
          const relativeToAssetsFolder = path.relative(path.join(settings.rootDir, './assets'), file);
          const outputPath = path.resolve(settings.distDir, relativeToAssetsFolder);
          fs.ensureDirSync(path.parse(outputPath).dir);
          fs.outputFileSync(outputPath, fs.readFileSync(file));
        }
      });
    },
  },
// replace your old rollup.config.js
const { getRollupConfig } = require("@elderjs/elderjs");
const svelteConfig = require("./svelte.config");

module.exports = [...getRollupConfig({ svelteConfig })];
Table of Contents:
  • Elder.js: An Opinionated, SEO focused, Svelte Framework.
    • Getting Started
      • Developing using the Template:
      • To Build/Serve HTML:
      • Elder.js Intro Video:
      • Why We Built Elder.js
    • Routes
      • Route.js
      • Why Routing Differs from Express-like Frameworks
      • Route.js Best Practices:
      • all() Function Spec
      • permalink() Function Spec
      • data() Function Spec
    • Hooks
      • The Goal of Elder.js Hooks
      • Hook Interface: the and Arrays
      • Hook Lifecycle
      • Hook List
        • 'customizeHooks'
        • 'bootstrap'
        • 'allRequests'
        • 'middleware'
        • 'request'
        • 'data'
        • 'shortcodes'
        • 'stacks'
        • 'head'
        • 'compileHtml'
        • 'html'
        • 'requestComplete'
        • 'error'
        • 'buildComplete'
      • Hook Example 1:
        • Fetching External Data
        • Adding DB to the Object
      • Where to Organize Hooks
    • Plugins
      • Official Plugins:
      • Other Plugin Ideas:
      • Writing Your Own Plugin
      • Plugin Example 1: S3 Upload
    • Svelte
      • Partial Hydration
      • Understanding Partial hydration
      • Hydration Options:
      • How partial hydration works under the covers.
      • Slots
    • Shortcodes
      • Overview
      • Use Cases For Shortcodes:
      • Shortcode Spec:
      • Using Shortcodes from Within Svelte
    • CSS
      • Including External CSS
    • Elder.js In Detail
      • Data Flow
        • 1. Everything starts in a site's route.js files
        • 2. Elder.js Bootstrap Itself
        • 3. The Hook is Run
        • 4. Full 'request' Objects are Built
        • 5. Hooks are Executed Until the function is Executed
        • 6. The 'data' hook is Executed
        • 7. The data Object is passed to the Svelte template
        • 8. The HTML returned by Blog.svelte is passed into Layout.svelte
        • 9. Page Generation Completes
      • Specifications and Config
        • Config:
        • Elder.js Expected file structure
        • Hook Specification
        • Plugin Specification
        • Route Specification
        • Shortcode Specification
        • Requirements for and fields
        • Stacks: Predictable String Concatenation
      • Automatic Behavior
      • Default Helpers
      • Elder.js Exports:
          • Replacements
      • Using typescript/postcss/scss or other Preprocessors
        • Extra setup for typescript
    • FAQ
      • How can I disable a hook?
      • Caching Client Assets
      • How can I copy files to public?
      • Why can't I use Svelte templates for data fetching/manipulation?
      • How Does Elder.js Differ From Sapper?
      • Adding Database Access
      • Customizing the HTML 'shell'
      • Upgrading to v1.0.0:
        • File / Hook Changes:
See an opportunity to improve the docs? Add it!
Elder Guide ™
  • Contact
  • Mission
  • Tech
  • Experts
  • Planning
  • Terms and Conditions
  • Privacy Policy
  • Sitemap
© 2020 Elder Guide LLC. All rights reserved.

All information, data and other material displayed on this website or included on other websites linked to this website is being provided for informational purposes only. This is not a substitute for medical, legal, financial or other professional advice. You should always consult with a qualified professional before making any decision with medical, legal or financial consequences. You should never disregard qualified professional advice based on information found on our website. We explicitly disclaim liability in connection with your use of this information.