Update blog to Nuxt 3

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.