Nuxt.js have been updated to Vue 3. Hence I decided to try it for my blog. Nuxt has module @nuxt/content to load markdown files. Let's see how it is modifiable for my purpose.
Create new project.
npx nuxi init nuxt-blog
npm install --save-dev @nuxt/content
npm install --save-dev sass
Add module and styles.
./nuxt.config.tsexport default defineNuxtConfig({
app: {
baseURL: "/blog",
rootId: "app", //html root div id
head: {
link: [
{
rel: "icon",
href: "/blog/favicon.svg"
}
],
title: 'Qwertoblog'
}
},
modules: [
'@nuxt/content'
],
css: [
'@/assets/style.scss'
],
content: {
}
})
Place articales to /content
directory.
Create default layout
./layouts/default.vue<template>
<div id="appWrap">
<TheHeader />
<section id="centerPart">
<slot />
</section>
<TheFooter />
</div>
</template>
Create pages.
Main list of posts
./pages/index.vue<template>
<PostsList />
</template>
The page for each post
./pages/posts/[slug].vue<template>
<div>
<Post v-if="post" :post-data="post" :more="true" />
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { useHead } from '@vueuse/head';
const route = useRoute();
const slug = <string>route.params["slug"];
const { data: post } = await useAsyncData('posts-list', () => queryContent('/')
.where({slug: slug})
.findOne());
useHead(computed(() => {
return {
title: post.value!.title + " | Qwertoblog"
};
})
);
</script>
Pagination
./pages/page/[page].vue<template>
<PostsList :index="index"/>
</template>
<script setup lang="ts">
const route = useRoute();
const index = Number(route.params["page"]);
</script>
Tags pages look similar.
pages
└─tag
├─[tag]
│ └─[page].vue
├─[tag_name].vue
└─index.vue
Placeholders tag
and
tag_name
are different because same are
treated as nested routes.
Components
Components are in ./components
directory.
Components that are used in markdown files should be
global. And they are in ./components/global
.
./components/PostsList.vue<template>
<div id="posts">
<div class="post_wrap"
v-for="post in posts" :key="post.slug">
<Post :post-data="post" :more="false"
/>
</div>
<div class="pagination">
<div class="pagination_older">
<NuxtLink v-if="hasPrev" :to="prevUrl" title="Old">« Old</NuxtLink>
</div>
<div class="pagination_home">
</div>
<div class="pagination_newer">
<NuxtLink v-if="hasNext" :to="nextUrl" title="New">New »</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useHead } from '@vueuse/head';
import { ParsedContent } from "~~/../../nuxt_content/src/runtime/types";
const config = useRuntimeConfig();
const POSTS_PER_PAGE: number = config.public.postsPerPage;
const props = defineProps({
index: Number,
tag: String
});
const { data: allPosts } = await useAsyncData('posts-list', () => queryContent('/')
.find());
useHead({
title: computed(() => {
let title = "Qwertoblog";
if (props.index) {
title = `Page ${props.index} | ${title}`;
}
if (props.tag) {
title = `Tag ${props.tag} | ${title}`;
}
return title;
})
});
const hasPrev = computed(() => {
return !(maxIndex.value == 1 || index.value == 1)
});
const hasNext = computed(() => {
return !!index.value && index.value != maxIndex.value;
});
const prevUrl = computed(() => {
let url = '';
if (!hasPrev) {
return '';
}
if (tag.value) {
url = `/tag/${tag.value}/page/${index.value - 1}`;
} else {
url = `/page/${index.value - 1}`;
}
return url;
});
const nextUrl = computed(() => {
let url = '';
if (!hasNext) {
return '';
}
if (index.value == maxIndex.value - 1) {
if (tag.value) {
url = `/tag/${tag.value}`;
} else {
url = `/`;
}
} else {
if (tag.value) {
url = `/tag/${tag.value}/page/${index.value + 1}`;
} else {
url = `/page/${index.value + 1}`;
}
}
return url;
});
const postsByTag = computed(() => {
let posts = allPosts!.value!
.filter((post: ParsedContent) => post.draft != true)
.filter((post: ParsedContent) =>
(tag.value == null
|| post!.tags
&& post.tags.some((t: string) => t.toLowerCase() == tag.value!.toLowerCase()))
)
.sort((a: ParsedContent, b: ParsedContent) => {
return new Date(b.date).getTime() - new Date(a.date).getTime()
});
return posts;
});
const tag = computed(() => {
return props.tag;
});
const index = computed<number>(() => {
let index = props.index;
if (!index) {
index = maxIndex.value;
}
return index;
});
const maxIndex = computed(() => {
return Math.ceil(postsByTag.value.length / POSTS_PER_PAGE);
});
const posts = computed(() => {
let posts = postsByTag.value;
let indexCur = index.value;
if (!indexCur) {
indexCur = maxIndex.value;
}
posts = posts.slice(
Math.max(0, posts.length - indexCur * POSTS_PER_PAGE),
posts.length - (indexCur - 1) * POSTS_PER_PAGE
);
return posts;
});
</script>
./components/Post.vue<template>
<div class="post">
<div class="post_title">
<LinkToPost :text="title" :outbound="outbound" :url="postUrl" />
</div>
<div class="post_meta">
{{postDate}}
<MetaTags :tags="fm.tags" />
</div>
<div class="post_content">
<ContentRenderer :value="post" :excerpt="!more">
<template #empty>
Rendering..
</template>
</ContentRenderer>
<LinkToPost v-if="readMore"
text="read more" :outbound="outbound" :url="postUrl" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { PropType } from "vue";
import { ParsedContent } from '~~/../../nuxt_content/src/runtime/types';
const props = defineProps({
postData: {
type: Object as PropType<ParsedContent>,
required: true
},
more: {
type: Boolean,
default: true
}
});
let post: ParsedContent = props.postData;
const title = computed(() => {
return fm.value && fm.value.title;
});
const postDate = computed<string>(() => {
const d: string = fm.value.date;
return d?.substring(0, 10);
});
const fm = computed(() => {
return post || <ParsedContent>{};
});
const postUrl = computed<string | undefined>(() => {
if (!fm.value) {
return undefined;
}
let postUrl: string | undefined = "/posts/" + fm.value.slug;
if (fm.value.url) {
postUrl = fm.value.url;
}
return postUrl;
});
const outbound = computed(() => {
return fm.value && fm.value.url != null;
});
const readMore = computed(() => {
if (props.more) {
// show full post
return false;
}
return outbound.value || (post && post.excerpt);
});
</script>
I use document property slug
. Nuxt Content
does not create this property. I create it after
processing. Nuxt Content creates _id
from
file name and it is different. Not wthat I need. Links to
my posts could be broken. So I should write code to format
slug from file name. I use Nitro plugin for this. Plugins
from directory ./server/plugins
are
autoregistered as Nitro plugins.
./server/plugins/nitro.tsexport default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('content:file:afterParse', (file) => {
if (file._id.endsWith('.md')) {
file.slug = file._file.replace(/\./g, '_');
}
});
});
Now it could work. But there are still problems.
Problems
Images and other assets
I can't access files from post that are in directory next
to post. Files should be moved to ./public
.
So after build images are not processed and they don't
have hash in filenames. Unused files and other materials
not for public go to distributive too. So I have to use 3
locations for one post.
Vue component in markdown
Component have to be with closing tag.
<MyComponent />
doen't work. I have to
fix in all posts to
<MyComponent></MyComponent>
.
Assets are in _nuxt directory
There is config property app.buildAssetsDir
.
I changed default value to /assets
.
Excerpt and description
I use property description
in frontmatter if
I don't use excerpt. Excerpt is creted from document with
tag <!-- more -->
. If I don't write
description and <!-- more -->
it means
my post is short and I use full content as description and
I don't show link "Read more".
Nuxt Content always generates the description property and
the excerpt. If there is no
<!-- more -->
the excerpt equals whole
document. So I can't understand in my Post.vue is this
post small or is there something to read more.
I have to rename property description
in all
posts. And add new property no_excerpt_here
.
See https://github.com/nuxt/content/pull/1801
Fenced code block info
Fenced code block in markdown can contain language and other info
```java and other info
// code to see
```
Nuxt Content takes this line and expects that it looks
like java [./filename.java] {1-3,5}
. Module
takes language, filename, and the code lines numbers to
highlight. Other information will be droped and forgeted.
See https://github.com/nuxt/content/pull/1800
It happens in remark-rehype
plugin after
default remark plugins and other added remark plugins. So
I can write my remark plugin to parse
meta
property from tag code
and
save it to another property. And there is another problem.
Remark and rehype plugins
Can't write just function as plugin
import myRemarkPlugin from './my-remark-plugin';
export default defineNuxtConfig({
content: {
markdown: {
remarkPlugins: {
"my-remark-plugin": {
instance: myRemarkPlugin
}
}
}
}
}
This code does not work. Because config goes through runtime config and function can't be serialized. So I need to create npm package next to blog project and install it. See https://github.com/nuxt/content/issues/1367
Excerpt is not highlighted
Highlighter works only on body. If I use code blocks in excerpt the post preview is grey. See https://github.com/nuxt/content/pull/1802
Hard to use another highlighter for code
I use Prism and have styles for it. With Nuxt Content I locked with Shiki. Shiki is not good for copy and paste. The pasted code is not formated, text without line brakes.
I can turn off highlighting and write remark-prism plugin. But Prism does not works with remark nodes.
I decided to use Prism after generation when the full html is ready.
I added hook on nitro event
./server/plugins/nitro.tsimport { JSDOM } from 'jsdom';
import Prism from 'prismjs';
import loadLanguages from 'prismjs/components/index.js';
export default defineNitroPlugin((nitro) => {
loadLanguages([...]);
nitro.hooks.hook('render:response', (response) => {
const jsdom = new JSDOM(response.body);
const document = jsdom.window.document;
Prism.highlightAllUnder(document, false);
response.body = jsdom.window.document.documentElement.outerHTML;
});
});
But the <code>
tag must have class
language-<language name>
. And I need to
add filename information.
So I can write rehype plugin to add class and filename. It means new package and npm install. No.
I can add my transformer. It executes after rehype plugins and before other default transformers (shiki highlighter is one of them). To add transformer I must write Nuxt module and register new hook on Content module. It means my module must be registered before content module.
./nuxt.config.tsmodules: [
'./modules/blog',
'@nuxt/content'
],
./modules/blog.mjsimport { defineNuxtModule } from '@nuxt/kit'
export default defineNuxtModule({
setup (_options, nuxt) {
// @ts-ignore
nuxt.hook('content:context', (contentContext) => {
contentContext.transformers.push('./modules/my-transformer.ts')
})
}
})
./modules/my-transformer.tsimport { visit } from 'unist-util-visit'
import type { ParsedContent } from '~~/../../nuxt_content/src/runtime/types';
export default {
name: 'my-transformer',
extensions: ['.md'],
transform: async (content: ParsedContent, options:any = {}) => {
const {
fileWrapClassName = 'has_file_name',
fileClassName = 'file_name',
fileParam = 'file_name'
} = options;
visit(
content.body,
(node: any) => (node.tag === 'code' && node?.props.code),
visitor
)
if (content.excerpt) {
visit(
content.excerpt,
(node: any) => (node.tag === 'code' && node?.props.code),
visitor
)
}
return content;
function visitor(node: any, index: number | null, parent: any) {
if (typeof parent === 'undefined') return;
const { language, meta } = node.props;
let params = undefined;
if (meta != null) {
params = meta.match(/([\w]+)=(?:")([^"]*)(?:")+/g)
?.filter((s: string) => s !== '')
.map((s: string) => s.trim())
.reduce((acc: {[index: string]:any}, item: string) => {
const index: number = item.indexOf('=')
acc[item.substring(0, index)] = item
.substring(index + 1)
.replace(/"(.+)"/, '$1')
return acc
}, {})
}
// add filename node to the <pre>
const pre = node.children![0];
const code = pre.children![0];
code.props.className = `language-${language}`;
const wrapClasses = [];
const preChildren = [];
if (params && params[fileParam]) {
wrapClasses.push(fileWrapClassName);
preChildren.push({
type: 'element',
tag: 'span',
props: {
className: fileClassName,
},
children: [
{
type: 'text',
value: params[fileParam],
},
]
});
}
preChildren.push(...pre.children);
pre.props.className = wrapClasses;
pre.children = preChildren;
}
}
}
Another method is define my
ProseCode
component.
./components/content/ProseCode.vue<template>
<pre :class="preClass">
<span v-if="fileName" class="file_name">{{ fileName }}</span>
<code :class="codeClass">{{ code }}</code>
</pre>
</template>
<style lang="scss">
pre {
white-space: normal;
code {
white-space: pre;
}
}
</style>
<script setup lang="ts">
const props = defineProps({
code: {
type: String,
default: ''
},
language: {
type: String,
default: null
},
filename: {
type: String,
default: null
},
highlights: {
type: Array as () => number[],
default: () => []
},
meta: {
type: String,
default: null
}
}
)
const codeClass = `language-${props.language}`;
let preClass: string[] = [];
let fileName = undefined;
if (props.meta != null) {
const metaParams = props.meta.match(/([\w]+)=(?:")([^"]*)(?:")+/g)
?.filter((s) => s !== '')
.map((s) => s.trim())
.reduce((acc: {[index: string]:any}, item) => {
const index: number = item.indexOf('=')
acc[item.substring(0, index)] = item
.substring(index + 1)
.replace(/"(.+)"/, '$1')
return acc;
}, {});
if (metaParams && metaParams["file_name"]) {
fileName = metaParams["file_name"];
preClass.push("has_file_name");
}
}
</script>
Now I can delete my transformer.
Can't turn off hydration
Nuxt generates a lot of scripts. Result page is heavy. Looks like every page contains source data for all posts.
I wrote hook to remove scripts after rendering.
./server/plugins/nitro.tsnitro.hooks.hook('render:response', (response) => {
response.body = response.body.replaceAll(/<\/div><script.*<\/body>/g, '<\/div><\/body>');
response.body = response.body.replaceAll(/<link rel="(preload|modulepreload|prefetch)" [^>]+>/g,'');
});
Page doesn't refresh in development
I run npm run dev
, open this post, make
changes in markdown and browser shows old text. Even
styles change doesn't work. I have to press F5.
Conclusion
Some errors have been fixed and pull requests have been
merged. Now I have two problems:
First, it is absolute path for images. They live in
/public
.
Second, HMR does not work. Text on page is not refreshed
on change in file. IT is very inconvenient.
So I returned to vite-ssg.
Nuxt + Content is framework that can force you to change code and compromise in order to build. Maintainers can make some changes and I think it could be fatal for my project.