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:
This required me to do some fun stuff with Nuxt Content, so let’s not delay any further!
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:
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.
The useCourseLessons
does a few things for me:
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.
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 makesnot-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:
<divclass="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 --><divclass="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>
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 patternconst isIncluded = map.includes.some((rule) => minimatch(path, rule));// If included, check if it's not excludedif (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 toconst pathVariants = getVariantsFromPath(el._path);// If one of the variants is a preview than we have accesslet 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 previewlet childHasAccess = childVariants.some((v) =>v.variant.includes('preview'));// If not, check if the user has access to the variantif (!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<spanclass="group-hover:translate-x-1 transition-transform inline-block">→</span></NuxtLink></div><!-- ... -->