Dynamically Updating my Landing Page with Nuxt Content

I recently spent some time updating the landing page for Clean Components Toolkit so that it will automatically update the outline as I update the course content itself:

/articles/dynamically-updating-nuxt-content/tools.jpg

This required me to do some fun stuff with Nuxt Content, so let’s not delay any further!

Updating the course outline

The Clean Components Toolkit is a collection of 21 tools (at time of writing), each of which are fully contained sections teaching you a new pattern or technique.

This course outline section is doing a few different things, which you can see in the screenshot above:

  1. Fetching each tool along with its meta data.
  2. Rendering that meta data along with some tags, and rendering the description.
  3. Linking directly to the tool in the platform, but only if the user has access. Some tools are designated as “previews” that anyone can view.

This is the logic that fetches that data:

const lessons = useCourseLessons({
path: '/courses/clean-components-toolkit/',
});
const filteredLessons = computed(() =>
lessons.value
.filter((lesson) => lesson.title !== 'Intro')
.filter((lesson) => lesson.meta.draft !== true)
);

Most of the heavy lifting is done by the useCourseLessons composable, which is used in my course platform for listing all the lessons of the current course, along with whether the user has access, and including all relevant meta data.

Reusing this for the landing page was fairly straightforward for me. I just needed to filter out the “Intro” section because that isn’t interesting to include in an outline, as well as any tool drafts.

But that’s not terribly interesting, so let’s see how this useCourseLessons composable actually works.

useCourseLessons Composable

The useCourseLessons does a few things for me:

  1. Grabs all lessons organized by course and chapter
  2. Includes all the meta data I need
  3. Checks whether the user has access, for each of the lessons, based on what courses they’ve purchased

1. Grab all lessons

I use the fetchContentNavigation method from Nuxt Content to get a tree structure of the contents of my ~/courses directory:

const { data: nav } = useNuxtData('course-nav');
if (!nav.value) {
useAsyncData<any[]>(
'course-nav',
() => fetchContentNavigation('courses')
);
}

The conditional and useNuxtData call are to cache the fetches, since the useCourseLessons composable is called from many components in my platform.

I don’t use queryContent because I don’t actually want the content itself, and I don’t want it in a flat array. I only need to know the paths, titles, and structure of the content itself.

2. Get the meta data

My directory structure looks like this:

content/
- reusable-components/
- ...
- clean-components-toolkit/
- 0.intro
- 1.props-down-events-up
- 2.lifting-state
...
- 20.item-component
- 21.strategy
- 99.bonus-videos

Each of these tool directories has the Markdown for the content, but they also have a meta.yaml file that looks like this:

description: 'Learn the underlying principle that makes
not-quite-mvc work so well.'
outcomes: ['principle', 'organizing-logic']

This file is where I define the description and the “outcomes”, those tags underneath the title.

To fetch all this meta data, I have this code in my useCourseLessons composable:

const { data: meta } = useNuxtData('course-meta');
if (!meta.value) {
useAsyncData('course-meta', () =>
queryContent()
.where({
_extension: { $eq: 'yaml' },
title: { $eq: 'Meta' },
})
.find()
);
}

I’m using the queryContent method from Nuxt Content to fetch all meta.yaml files in my ~/content directory.

Then, I use a computed to stitch the fetched meta data and this nav data together using the _path property that Nuxt Content attaches.

I use this metadata in my Tools.vue component on the landing page like this:

<div
class="p-6 max-w-xl bg-white rounded-lg hover:scale-105 hover:shadow-xl transition-all duration-150 group border-4 border-transparent hover:border-mt-blue flex flex-col justify-between"
v-for="chapter in filteredLessons"
:key="chapter.title"
>
<div class="flex flex-col justify-start">
<div class="flex justify-between items-start relative">
<h3 class="header-5 mt-0 mb-1 text-type-dark">
{{ chapter.title }}
</h3>
<!-- Show video and word length on hover -->
<div
class="font-mono absolute top-0 right-0 tabular-nums text-sm font-bold text-gray-600 rounded-full px-3 py-1 min-w-max opacity-0 group-hover:opacity-100 transition-opacity duration-300"
>
<div class="flex justify-between">
<span class="mr-2">Video:</span
><span>{{ formatTime(chapter.duration) }}</span>
</div>
<div class="flex justify-between">
<span class="mr-2">Written:</span
><span>
{{ formatReadingTime(chapter.wordCount) }}
</span>
</div>
</div>
</div>
<!-- Render outcomes as tags -->
<OutcomeList class="mb-6 max-w-sm" :list="chapter.meta.outcomes" />
<!-- Write out tool description -->
<div>{{ chapter.meta.description }}</div>
</div>
<!-- ... -->
</div>

3. Checking if the user has access

This could probably a topic for an entirely separate article, since this gets fairly complicated. I’m also likely going to refactor to use the new nuxt-authorization package to simplify my logic here.

But I’ll try to keep this section succinct!

I use a route-based authorization system. My platform supports multiple courses, each with multiple variants. For example, Clean Components Toolkit has premium and mastery variants, and so does Reusable Components. There is also a special preview variant so I can show preview content from each course.

For each variant I can specify an array of globs that the variant includes and excludes:

// ...
{
course: 'Clean Components Toolkit',
variant: 'preview',
includes: [
'/courses/clean-components-toolkit/intro',
'/courses/clean-components-toolkit/intro/**',
'/courses/clean-components-toolkit/props-down-events-up',
'/courses/clean-components-toolkit/props-down-events-up/**',
'/courses/clean-components-toolkit/lifting-state',
'/courses/clean-components-toolkit/lifting-state/**',
'/courses/clean-components-toolkit',
],
excludes: ['/courses/clean-components-toolkit/intro/community-support'],
},
// ...

The preview variant is a special variant that everyone has access to. Here, I’m including access to the intro, the first two tools, and also the main dashboard of the course. I’m also excluding access to the community-support page since that includes an invite link to the Discord that shouldn’t be public.

When I get the entire nav object, I use this method to get all the variants that have access to that path:

export const getVariantsFromPath = (path: string) =>
pathMap
.filter((map) => {
// Check if the path matches any inclusion pattern
const isIncluded = map.includes.some((rule) => minimatch(path, rule));
// If included, check if it's not excluded
if (isIncluded) {
const isExcluded =
map.excludes?.some((rule) => minimatch(path, rule)) || false;
return !isExcluded;
}
return false;
})
.map((map) => ({
course: map.course,
variant: `${map.course}: ${map.variant}`,
}));

Then I can compare to see if the user has these variants (or if the path is part of a preview). This has to happen at two levels, the lesson (which is badly named as el in this method) and all of it’s children. This is because I want to be able to include access to a lesson, but lock access to sub-lessons for more control:

const filterByAccess = (variants) => (el) => {
// Filter out any lessons that the user doesn't have access to
const pathVariants = getVariantsFromPath(el._path);
// If one of the variants is a preview than we have access
let hasAccess = false;
if (pathVariants.some((v) => v.variant.includes('preview'))) {
hasAccess = true;
} else {
hasAccess = pathVariants.some((v) =>
variants.value?.some(({ name }) => name === v.variant)
);
}
const mappedChildren = el.children.map((child) => {
const childVariants = getVariantsFromPath(child._path);
// First check if this is a preview
let childHasAccess = childVariants.some((v) =>
v.variant.includes('preview')
);
// If not, check if the user has access to the variant
if (!childHasAccess) {
childHasAccess = childVariants.some((v) =>
variants.value?.some(({ name }) => name === v.variant)
);
}
return {
...child,
hasAccess: childHasAccess,
};
});
return {
...el,
children: mappedChildren,
hasAccess,
};
};

(I’ll leave it as an exercise to the reader to refactor and remove duplicated code if you don’t think this is DRY enough).

Then, in my Tools.vue component, I can check the hasAccess properties of the lesson (no need to check sub-lesson here):

<!-- ... -->
<div v-if="chapter.hasAccess" class="mt-4">
<NuxtLink :to="`${chapter._path}/intro`" class="cursor-pointer">
Check out this tool
<span
class="group-hover:translate-x-1 transition-transform inline-block"
>
&rarr;
</span>
</NuxtLink>
</div>
<!-- ... -->