-
-
Notifications
You must be signed in to change notification settings - Fork 1
feat(blog): add blog section with Nuxt Content and Valibot schemas #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bb84d15
a30ec05
e958783
011e3c0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| <template> | ||
| <div class="flex items-center"> | ||
| <UButton | ||
| icon="i-simple-icons-discord" | ||
| to="https://join.wolfstar.rocks" | ||
| , | ||
| target="_blank" | ||
| variant="ghost" | ||
| color="neutral" | ||
| size="xs" | ||
| > | ||
| <span class="sr-only">Nuxt on Discord</span> | ||
| </UButton> | ||
| <UButton | ||
| icon="i-simple-icons-github" | ||
| to="https://github.com/wolfstar-project" | ||
| , | ||
| target="_blank" | ||
| variant="ghost" | ||
| color="neutral" | ||
| size="xs" | ||
| > | ||
| <span class="sr-only">Nuxt on GitHub</span> | ||
| </UButton> | ||
| </div> | ||
| </template> | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,30 @@ | ||||||||||||||||||||||||||||||||||||||||
| import type { BlogArticle } from "~/types"; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export const useBlog = () => { | ||||||||||||||||||||||||||||||||||||||||
| const { data: articles, refresh } = useAsyncData<BlogArticle[]>( | ||||||||||||||||||||||||||||||||||||||||
| "blog", | ||||||||||||||||||||||||||||||||||||||||
| async () => { | ||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||
| queryCollection("blog") | ||||||||||||||||||||||||||||||||||||||||
| .where("extension", "=", "md") | ||||||||||||||||||||||||||||||||||||||||
| /* .select('title', 'date', 'image', 'description', 'path', 'authors', 'category') */ | ||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Remove commented-out code. Per coding guidelines, commented-out code should not be left without associated tickets. Either remove this line or create a tracking issue if field selection optimization is planned. 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| .order("date", "DESC") | ||||||||||||||||||||||||||||||||||||||||
| .all() | ||||||||||||||||||||||||||||||||||||||||
| .then((res) => res.filter((article) => article.path !== "/blog")) | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+6
to
+14
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add filter to exclude draft posts. The query fetches all blog posts without filtering out drafts. Posts with 🔒 Proposed fix to filter draft posts return (
queryCollection("blog")
.where("extension", "=", "md")
+ .where("draft", "=", false)
/* .select('title', 'date', 'image', 'description', 'path', 'authors', 'category') */
.order("date", "DESC")
.all()
.then((res) => res.filter((article) => article.path !== "/blog"))
);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| { default: () => [] }, | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| async function fetchList() { | ||||||||||||||||||||||||||||||||||||||||
| if (!articles.value?.length) { | ||||||||||||||||||||||||||||||||||||||||
| return refresh(); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||
| articles, | ||||||||||||||||||||||||||||||||||||||||
| // featuredArticle, | ||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Remove commented-out code. The 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| fetchList, | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { useClipboard as _useClipboard } from "@vueuse/core"; | ||
|
|
||
| type toastOptions = { | ||
| title?: string; | ||
| description?: string; | ||
| icon?: string; | ||
| color?: | ||
| | "primary" | ||
| | "secondary" | ||
| | "info" | ||
| | "success" | ||
| | "warning" | ||
| | "error" | ||
| | "important" | ||
| | "neutral"; | ||
| }; | ||
|
Comment on lines
+3
to
+16
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Use PascalCase for type names. As per coding guidelines, types and interfaces should use PascalCase. ♻️ Proposed fix-type toastOptions = {
+type ToastOptions = {
title?: string;
description?: string;
icon?: string;
color?:
| "primary"
| "secondary"
| "info"
| "success"
| "warning"
| "error"
| "important"
| "neutral";
};🤖 Prompt for AI Agents |
||
|
|
||
| export const useClipboard = () => { | ||
| const { copy: _copy, copied } = _useClipboard(); | ||
|
|
||
| const toast = useToast(); | ||
|
|
||
| const copy = (source: string, optionsOptions?: toastOptions) => { | ||
| _copy(source); | ||
| if (optionsOptions) { | ||
| toast.add(optionsOptions); | ||
| } | ||
| }; | ||
|
RedStar071 marked this conversation as resolved.
|
||
|
|
||
| return { | ||
| copy, | ||
| copied, | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,7 +22,7 @@ export const useFooter = () => { | |
| class: "link-hover", | ||
| icon: "ph:newspaper-clipping-duotone", | ||
| label: "Blog", | ||
| to: "https://blog.wolfstar.rocks", | ||
| to: "/blog", | ||
| ui: { linkLeadingIcon: "bg-primary" }, | ||
|
Comment on lines
22
to
26
|
||
| }, | ||
| ], | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,6 +55,10 @@ export function useHeader() { | |
| label: "Commands", | ||
| to: "/commands", | ||
| }, | ||
| { | ||
| label: "Blog", | ||
| to: "/blog", | ||
| }, | ||
|
Comment on lines
+58
to
+61
|
||
| ]); | ||
|
|
||
| const mobileLinks = computed(() => [ | ||
|
|
@@ -88,6 +92,10 @@ export function useHeader() { | |
| label: "Commands", | ||
| to: "/commands", | ||
| }, | ||
| { | ||
| label: "Blog", | ||
| to: "/blog", | ||
| }, | ||
| ...(currentApp.value.invite !== "#" | ||
| ? [ | ||
| { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| <script setup lang="ts"> | ||
| import { isNullOrUndefined } from "@sapphire/utilities/isNullOrUndefined"; | ||
| import { kebabCase } from "scule"; | ||
|
|
||
| const route = useRoute(); | ||
| const { copy } = useClipboard(); | ||
|
|
||
| const [{ data: article }, { data: surround }] = await Promise.all([ | ||
| useAsyncData(kebabCase(route.path), () => queryCollection("blog").path(route.path).first()), | ||
| useAsyncData(`${kebabCase(route.path)}-surround`, () => { | ||
| return queryCollectionItemSurroundings("blog", route.path, { | ||
| fields: ["description"], | ||
| }).order("date", "DESC"); | ||
| }), | ||
| ]); | ||
|
|
||
| if (isNullOrUndefined(article.value)) { | ||
| throw createError({ statusCode: 404, statusMessage: "Article not found", fatal: true }); | ||
| } | ||
|
Comment on lines
+17
to
+19
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Treat drafted posts as not found on the detail route. The schema includes 🔒 Proposed fix-if (isNullOrUndefined(article.value)) {
+if (isNullOrUndefined(article.value) || article.value.draft) {
throw createError({ statusCode: 404, statusMessage: "Article not found", fatal: true });
}🤖 Prompt for AI Agents |
||
|
|
||
| const title = article.value.seo?.title || article.value.title; | ||
| const description = article.value.seo?.description || article.value.description; | ||
|
|
||
| useSeoMeta({ | ||
| titleTemplate: "%s · Wolfstar Blog", | ||
| title, | ||
|
Comment on lines
+24
to
+26
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Align page title format with repository accessibility convention. Line 28 uses ♻️ Proposed fix- titleTemplate: "%s · Wolfstar Blog",
+ titleTemplate: "%s - blog - Wolfstar",Based on learnings: "Set a descriptive page title using the format 'Unique page - section - site'." 🤖 Prompt for AI Agents |
||
| description, | ||
| ogDescription: description, | ||
| ogTitle: `${title} · Wolfstar Blog`, | ||
| }); | ||
|
|
||
| if (article.value.image) { | ||
| defineOgImage({ url: article.value.image }); | ||
| } else { | ||
| defineOgImageComponent("Docs", { | ||
| headline: "Blog", | ||
| title, | ||
| description, | ||
| }); | ||
|
RedStar071 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| function formatSocialIntentQueryText(handle: string | undefined): string { | ||
| const credit = handle ? ` by @${handle}` : ""; | ||
| const body = article.value!.title + credit; | ||
| const link = `${getOrigin()}${article.value!.path}`; | ||
| return encodeURIComponent(`${body}\n\n${link}`); | ||
| } | ||
|
|
||
| const authorHandles: { twitter?: string; bluesky?: string } = { | ||
| twitter: article.value.authors?.[0]?.twitter, | ||
| bluesky: article.value.authors?.[0]?.bluesky, | ||
| }; | ||
|
|
||
| const socialLinks = computed(() => | ||
| !article.value | ||
| ? [] | ||
| : [ | ||
| { | ||
| label: "Bluesky", | ||
| icon: "i-simple-icons-bluesky", | ||
| to: `https://bsky.app/intent/compose?text=${formatSocialIntentQueryText(authorHandles.bluesky)}`, | ||
| }, | ||
| { | ||
| label: "X", | ||
| icon: "i-simple-icons-x", | ||
| to: `https://x.com/intent/tweet?text=${formatSocialIntentQueryText(authorHandles.twitter)}`, | ||
| }, | ||
| ], | ||
| ); | ||
|
|
||
| function copyLink() { | ||
| copy(`${getOrigin()}${article.value?.path || "/"}`, { | ||
| title: "Link copied to clipboard", | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| icon: "i-lucide-copy-check", | ||
| }); | ||
| } | ||
|
|
||
| const links = [ | ||
| { | ||
| icon: "i-lucide-pen", | ||
| label: "Edit this article", | ||
| to: `https://github.com/wolfstar-project/wolfstar.rocks/edit/main/content/${article.value.stem}.md`, | ||
| target: "_blank", | ||
| }, | ||
| { | ||
| icon: "i-lucide-star", | ||
| label: "Star on GitHub", | ||
| to: "https://github.com/wolfstar-project/wolfstar.rocks", | ||
| target: "_blank", | ||
| }, | ||
| ]; | ||
| </script> | ||
|
|
||
| <template> | ||
| <UContainer> | ||
| <UPage v-if="article"> | ||
| <UPageHeader | ||
| :title="article.title" | ||
| :description="article.description" | ||
| :ui="{ headline: 'flex flex-col gap-y-8 items-start' }" | ||
| > | ||
| <template #headline> | ||
| <UBreadcrumb | ||
| :items="[ | ||
| { label: 'Blog', icon: 'i-lucide-newspaper', to: '/blog' }, | ||
| { label: article.title }, | ||
| ]" | ||
| class="max-w-full" | ||
| /> | ||
| <div class="flex items-center space-x-2"> | ||
| <span> | ||
| {{ article.category }} | ||
| </span> | ||
| <span class="text-muted" | ||
| >· <time>{{ | ||
| new Date(article.date).toLocaleDateString("en", { | ||
| year: "numeric", | ||
| month: "short", | ||
| day: "numeric", | ||
| }) | ||
| }}</time></span | ||
|
Comment on lines
+114
to
+121
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cd /tmp && find . -type f -name "[slug].vue" 2>/dev/null | head -5Repository: wolfstar-project/wolfstar.rocks Length of output: 57 🏁 Script executed: git ls-files app/pages/blog/ | head -20Repository: wolfstar-project/wolfstar.rocks Length of output: 125 🏁 Script executed: head -150 app/pages/blog/[slug].vue | tail -50Repository: wolfstar-project/wolfstar.rocks Length of output: 1428 🏁 Script executed: cat -n app/pages/blog/[slug].vue | sed -n '110,125p'Repository: wolfstar-project/wolfstar.rocks Length of output: 563 🏁 Script executed: cat -n app/pages/blog/index.vue | grep -A 10 -B 10 "article.date\|toLocaleDateString"Repository: wolfstar-project/wolfstar.rocks Length of output: 770 🏁 Script executed: rg "new Date\(article\.date\)" app/Repository: wolfstar-project/wolfstar.rocks Length of output: 240 🏁 Script executed: rg "toLocaleDateString" app/ -B 2 -A 2Repository: wolfstar-project/wolfstar.rocks Length of output: 638 Fix date interpretation timezone issue in both blog files.
🕒 UTC-safe formatting <script setup lang="ts">
+const BLOG_DATE_FORMAT = new Intl.DateTimeFormat("en", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ timeZone: "UTC",
+});
</script>
@@
- new Date(article.date).toLocaleDateString("en", {
- year: "numeric",
- month: "short",
- day: "numeric",
- })
+ BLOG_DATE_FORMAT.format(new Date(`${article.date}T00:00:00Z`))🤖 Prompt for AI Agents |
||
| > | ||
| </div> | ||
| </template> | ||
|
|
||
| <div class="mt-4 flex flex-wrap items-center gap-6"> | ||
| <UUser | ||
| v-for="(author, index) in article.authors" | ||
| :key="index" | ||
| v-bind="author" | ||
| :description="author.to ? `@${author.to.split('/').pop()}` : undefined" | ||
| /> | ||
| </div> | ||
| </UPageHeader> | ||
|
|
||
| <UPage class="lg:gap-24"> | ||
| <UPageBody> | ||
| <ContentRenderer v-if="article.body" :value="article" /> | ||
|
|
||
| <div class="not-prose mt-12 flex items-center justify-between"> | ||
| <ULink to="/blog" class="text-primary"> ← Back to blog </ULink> | ||
| <div class="flex items-center justify-end gap-1.5"> | ||
| <UButton | ||
| icon="i-lucide-link" | ||
| variant="ghost" | ||
| color="neutral" | ||
| @click="copyLink" | ||
| > | ||
| <span class="sr-only">Copy URL</span> | ||
| Copy URL | ||
| </UButton> | ||
| <UButton | ||
| v-for="(link, index) in socialLinks" | ||
| :key="index" | ||
| v-bind="link" | ||
| variant="ghost" | ||
| color="neutral" | ||
| target="_blank" | ||
| > | ||
| <span class="sr-only">Wolfstar on {{ link.label }}</span> | ||
| </UButton> | ||
| </div> | ||
| </div> | ||
|
|
||
| <USeparator v-if="surround?.length" /> | ||
|
|
||
| <UContentSurround :surround="surround" /> | ||
| </UPageBody> | ||
|
|
||
| <template #right> | ||
| <UContentToc | ||
| v-if="article.body && article.body.toc" | ||
| :links="article.body.toc.links" | ||
| title="Table of Contents" | ||
| highlight | ||
| > | ||
| <template #bottom> | ||
| <div class="hidden space-y-6 lg:block"> | ||
| <UPageLinks title="Links" :links="links" /> | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| <USeparator type="dashed" /> | ||
| <SocialLinks /> | ||
| </div> | ||
| </template> | ||
| </UContentToc> | ||
| </template> | ||
| </UPage> | ||
| </UPage> | ||
| </UContainer> | ||
| </template> | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,65 @@ | ||||||
| <script setup lang="ts"> | ||||||
| const { data: page } = await useAsyncData("blog-landing", () => queryCollection("blog").first()); | ||||||
| if (!page.value) { | ||||||
| throw createError({ statusCode: 404, statusMessage: "Page not found", fatal: true }); | ||||||
| } | ||||||
|
|
||||||
| const { articles, fetchList } = useBlog(); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The ♻️ Option 1: Call fetchList on mount const { articles, fetchList } = useBlog();
+
+await fetchList();♻️ Option 2: Remove unused import-const { articles, fetchList } = useBlog();
+const { articles } = useBlog();📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
|
|
||||||
| useSeoMeta({ | ||||||
| titleTemplate: "%s", | ||||||
| title: page.value.title, | ||||||
| description: page.value.description, | ||||||
| ogDescription: page.value.description, | ||||||
| ogTitle: page.value.title, | ||||||
| }); | ||||||
| </script> | ||||||
|
|
||||||
| <template> | ||||||
| <UContainer v-if="page"> | ||||||
| <UPageHero :title="page.title" :description="page.description" orientation="horizontal"> | ||||||
| </UPageHero> | ||||||
|
|
||||||
| <UPageBody> | ||||||
| <UContainer> | ||||||
| <UBlogPosts class="mb-12 md:grid-cols-2 lg:grid-cols-3"> | ||||||
| <h2 class="sr-only">Post</h2> | ||||||
| <UBlogPost | ||||||
| v-for="(article, index) in articles" | ||||||
| :key="article.path" | ||||||
| :to="article.path" | ||||||
| :title="article.title" | ||||||
| :description="article.description" | ||||||
| :image="{ | ||||||
| src: article.image, | ||||||
| width: index === 0 ? 672 : 437, | ||||||
| height: index === 0 ? 378 : 246, | ||||||
| alt: `${article.title} image`, | ||||||
| }" | ||||||
| :date=" | ||||||
| new Date(article.date).toLocaleDateString('en', { | ||||||
| year: 'numeric', | ||||||
| month: 'short', | ||||||
| day: 'numeric', | ||||||
| }) | ||||||
| " | ||||||
| :authors=" | ||||||
| article.authors.map((author) => ({ | ||||||
| ...author, | ||||||
| avatar: { | ||||||
| ...author.avatar, | ||||||
| loading: 'lazy', | ||||||
| alt: `${author.name} avatar`, | ||||||
| }, | ||||||
| })) | ||||||
| " | ||||||
| :badge="{ label: article.category, color: 'primary', variant: 'subtle' }" | ||||||
| :variant="index === 0 ? 'outline' : 'subtle'" | ||||||
| :orientation="index === 0 ? 'horizontal' : 'vertical'" | ||||||
| :class="[index === 0 && 'col-span-full']" | ||||||
| /> | ||||||
| </UBlogPosts> | ||||||
| </UContainer> | ||||||
| </UPageBody> | ||||||
| </UContainer> | ||||||
| </template> | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import type { BlogCollectionItem } from "@nuxt/content"; | ||
|
|
||
| export type BlogArticle = BlogCollectionItem; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct the link labels and add
relon the external buttons.These controls point to WolfStar destinations, but the current accessible names still announce “Nuxt”. Also,
target="_blank"should be paired withrel="noopener noreferrer".♻️ Proposed fix
🤖 Prompt for AI Agents