diff --git a/.env.example b/.env.example index 1a834d29c..a6cea7df4 100644 --- a/.env.example +++ b/.env.example @@ -25,9 +25,15 @@ NUXT_BETTER_AUTH_SECRET= # Session cookie name — MUST differ per deployment to prevent cookie bleed across subdomains # Production (wolfstar.rocks): leave blank (defaults to wolfstar-session) # Beta/staging (beta.wolfstar.rocks): wolfstar-session-beta -NUXT_SESSION_COOKIE_NAME= - -# Secret for generating OG images (generate a secure random string) +NUXT_SESSION_COOKIE_NAME= + +# Nuxt Studio GitHub OAuth credentials (local development only) +# Nuxt Studio is registered only when running `nuxt dev`, so it is never exposed +# in production. Configure the callback URL as http://localhost:3000/__nuxt_studio/auth/github +NUXT_STUDIO_AUTH_GITHUB_CLIENT_ID= +NUXT_STUDIO_AUTH_GITHUB_CLIENT_SECRET= + +# Secret for generating OG images (generate a secure random string) # You can use: openssl rand -base64 32 NUXT_IMAGE_PROXY_SECRET= # =================================== diff --git a/app/components/OgImage/BlogPost.takumi.vue b/app/components/OgImage/BlogPost.takumi.vue new file mode 100644 index 000000000..a0357d0fe --- /dev/null +++ b/app/components/OgImage/BlogPost.takumi.vue @@ -0,0 +1,210 @@ + + + diff --git a/app/components/OgImage/Changelog.takumi.vue b/app/components/OgImage/Changelog.takumi.vue new file mode 100644 index 000000000..bbbcca2de --- /dev/null +++ b/app/components/OgImage/Changelog.takumi.vue @@ -0,0 +1,110 @@ + + + diff --git a/app/components/changelog/ContributorMention.vue b/app/components/changelog/ContributorMention.vue new file mode 100644 index 000000000..959099da8 --- /dev/null +++ b/app/components/changelog/ContributorMention.vue @@ -0,0 +1,74 @@ + + + diff --git a/app/components/changelog/Contributors.vue b/app/components/changelog/Contributors.vue new file mode 100644 index 000000000..a53386c91 --- /dev/null +++ b/app/components/changelog/Contributors.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/composables/useFooter.ts b/app/composables/useFooter.ts index 2a85cd527..7fc5503e7 100644 --- a/app/composables/useFooter.ts +++ b/app/composables/useFooter.ts @@ -17,7 +17,7 @@ export const useFooter = () => { { class: "link-hover", label: "Changelog", - to: "https://github.com/wolfstar-project/wolfstar.rocks/releases", + to: "/changelog", }, ], label: "Product", diff --git a/app/composables/useHeader.ts b/app/composables/useHeader.ts index a9d843736..3e2354cf6 100644 --- a/app/composables/useHeader.ts +++ b/app/composables/useHeader.ts @@ -55,6 +55,10 @@ export function useHeader() { label: "Blog", to: "/blog", }, + { + label: "Changelog", + to: "/changelog", + }, ]); const mobileLinks = computed(() => [ @@ -95,6 +99,10 @@ export function useHeader() { label: "Blog", to: "/blog", }, + { + label: "Changelog", + to: "/changelog", + }, { icon: "lucide:github", label: "GitHub", diff --git a/app/pages/(marketing)/blog/[slug].vue b/app/pages/(marketing)/blog/[slug].vue index e9f48e571..631a03bb3 100644 --- a/app/pages/(marketing)/blog/[slug].vue +++ b/app/pages/(marketing)/blog/[slug].vue @@ -132,9 +132,14 @@ useSeoMeta({ }); if (!post.image) { - defineOgImage("Page", { + defineOgImage("BlogPost", { title, description, + date: post.date, + authors: post.authors.map((author) => ({ + name: author.name, + avatar: author.avatar?.src, + })), }); } diff --git a/app/pages/(marketing)/changelog/index.vue b/app/pages/(marketing)/changelog/index.vue new file mode 100644 index 000000000..a1b9613af --- /dev/null +++ b/app/pages/(marketing)/changelog/index.vue @@ -0,0 +1,169 @@ + + + diff --git a/app/utils/changelog-heading-ids.ts b/app/utils/changelog-heading-ids.ts new file mode 100644 index 000000000..e1aa9c47f --- /dev/null +++ b/app/utils/changelog-heading-ids.ts @@ -0,0 +1,72 @@ +interface HastNode { + type: string; + tagName?: string; + value?: string; + properties?: Record; + children?: HastNode[]; +} + +const HEADING_TAG = /^h[1-6]$/; + +/** + * Slugifies heading text into an id-safe fragment (lowercase alphanumerics + * joined by single dashes, no leading/trailing dash). + */ +export function slugifyHeadingText(value: string): string { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function extractText(node: HastNode): string { + if (node.type === "text") { + return node.value ?? ""; + } + + return (node.children ?? []).map(extractText).join(""); +} + +/** + * Rewrites heading ids in a parsed hast tree so they are prefixed with a + * per-release slug and de-duplicated within the tree. + * + * Each changelog release renders in its own `` instance with a fresh + * slugger, so without a prefix identical section headings ("Fixes", "Chore", + * ...) collide across releases and fail html-validate's `no-dup-id` rule when + * the page is prerendered. + */ +export function prefixHeadingIds(tree: HastNode, prefix: string): void { + const base = slugifyHeadingText(prefix); + const seen = new Map(); + + const walk = (node: HastNode): void => { + if (node.type === "element" && node.tagName && HEADING_TAG.test(node.tagName)) { + const slug = slugifyHeadingText(extractText(node)) || "section"; + const scoped = base ? `${base}-${slug}` : slug; + const count = seen.get(scoped) ?? 0; + seen.set(scoped, count + 1); + node.properties = node.properties ?? {}; + node.properties.id = count === 0 ? scoped : `${scoped}-${count}`; + } + + for (const child of node.children ?? []) { + walk(child); + } + }; + + walk(tree); +} + +/** + * MDC/rehype plugin factory. Registered as a `parserOptions.rehype.plugins` + * entry on the changelog `` so each release gets release-scoped heading + * ids. MDC's compiler keeps a pre-set `properties.id`, so this runs before its + * own slug generation. + */ +export function rehypeChangelogHeadingIds(options: { prefix: string }) { + return (tree: HastNode): void => { + prefixHeadingIds(tree, options.prefix); + }; +} diff --git a/app/utils/format-date-by-locale.ts b/app/utils/format-date-by-locale.ts index faf7349bd..5b0805c91 100644 --- a/app/utils/format-date-by-locale.ts +++ b/app/utils/format-date-by-locale.ts @@ -1,7 +1,12 @@ export function formatDateByLocale(locale: string, date: string | number | Date) { + // Pin the calendar day to UTC so the server (UTC host) and the client + // (any local time zone) render the same date. Without this, a timestamp + // near UTC midnight formats as a different day during hydration in + // western time zones, causing a hydration mismatch. return new Date(date).toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric", + timeZone: "UTC", }); } diff --git a/app/utils/marketing-routes.ts b/app/utils/marketing-routes.ts index b5cfe9fbb..45ffe955b 100644 --- a/app/utils/marketing-routes.ts +++ b/app/utils/marketing-routes.ts @@ -8,6 +8,7 @@ export const MARKETING_PATHS = [ "/profile", "/account", "/blog", + "/changelog", ] as const; export function isMarketingPath(path: string): boolean { diff --git a/app/utils/parse-release-contributors.ts b/app/utils/parse-release-contributors.ts new file mode 100644 index 000000000..48911edcc --- /dev/null +++ b/app/utils/parse-release-contributors.ts @@ -0,0 +1,75 @@ +export interface ReleaseContributor { + name: string; + username: string; +} + +export interface ChangelogContributorItem { + name: string; + username: string; + commits: number; + hasContributed: boolean; + avatarSrc: string; +} + +export interface ParseReleaseContributorsResult { + bodyMarkdown: string; + contributors: ReleaseContributor[]; +} + +/** Matches `### ❤️ Contributors` / `### Contributors` (optional emoji). */ +const CONTRIBUTORS_HEADING_RE = /^### (?:\u2764\uFE0F? )?Contributors$/m; + +/** Trailing ` (@username)` on a contributor list item. */ +const CONTRIBUTOR_HANDLE_RE = / \(@([A-Z0-9](?:[A-Z0-9-]{0,37}[A-Z0-9])?)\)$/i; + +function parseContributorLine(line: string): ReleaseContributor | null { + const trimmed = line.trim(); + if (!trimmed.startsWith("- ")) return null; + + const handleMatch = CONTRIBUTOR_HANDLE_RE.exec(trimmed); + if (!handleMatch || handleMatch.index === undefined) return null; + + const username = handleMatch[1]; + const name = trimmed.slice(2, handleMatch.index).trim(); + if (!name || !username) return null; + + return { name, username }; +} + +/** + * Splits release-notes markdown into the body (without the Contributors section) + * and a structured list of contributors parsed from `- Name (@username)` lines. + */ +export function parseReleaseContributors(markdown: string): ParseReleaseContributorsResult { + const headingMatch = CONTRIBUTORS_HEADING_RE.exec(markdown); + if (!headingMatch || headingMatch.index === undefined) { + return { bodyMarkdown: markdown, contributors: [] }; + } + + const sectionStart = headingMatch.index; + const afterHeading = markdown.slice(sectionStart + headingMatch[0].length); + const nextHeadingOffset = afterHeading.search(/^#{1,6} /m); + const sectionBody = + nextHeadingOffset === -1 ? afterHeading : afterHeading.slice(0, nextHeadingOffset); + const sectionEnd = + nextHeadingOffset === -1 + ? markdown.length + : sectionStart + headingMatch[0].length + nextHeadingOffset; + + const contributors: ReleaseContributor[] = []; + const seenUsernames = new Set(); + + for (const line of sectionBody.split("\n")) { + const parsed = parseContributorLine(line); + if (!parsed || seenUsernames.has(parsed.username.toLowerCase())) continue; + + seenUsernames.add(parsed.username.toLowerCase()); + contributors.push(parsed); + } + + const before = markdown.slice(0, sectionStart).trimEnd(); + const after = markdown.slice(sectionEnd).trimStart(); + const bodyMarkdown = [before, after].filter(Boolean).join("\n\n"); + + return { bodyMarkdown, contributors }; +} diff --git a/knip.ts b/knip.ts index 5d24316c9..d02ce0e79 100644 --- a/knip.ts +++ b/knip.ts @@ -69,6 +69,9 @@ const config: KnipConfig = { "@nuxt/icon", "nuxt-security", + /** Registered as a Nuxt module only in dev via a conditional spread in nuxt.config.ts, so knip can't resolve it statically */ + "nuxt-studio", + /** Used in the app in guild/logs components */ "@tanstack/table-core", diff --git a/nuxt.config.ts b/nuxt.config.ts index c12998da5..c86ee9998 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,7 +1,7 @@ import netlifyNuxt from "@netlify/nuxt"; import { auditRedactPreset } from "evlog"; import { createResolver } from "nuxt/kit"; -import { isCI, isTest, provider } from "std-env"; +import { isCI, isDevelopment, isTest, provider } from "std-env"; import { pwa } from "./config/pwa"; import { generateRuntimeConfig } from "./server/utils/runtimeConfig"; @@ -16,6 +16,11 @@ export default defineNuxtConfig({ modules: [ "@nuxt/ui", "@nuxt/content", + // Nuxt Studio is a local content-editing tool. It ships multi-MB editor + // bundles that overflow the PWA precache (breaking the build) and registers + // Vite plugins that break the rolldown-based Vitest environment, so only + // load it during local development. + ...(isDevelopment ? ["nuxt-studio"] : []), "@nuxt/image", "@nuxt/hints", "@nuxt/fonts", @@ -263,6 +268,9 @@ export default defineNuxtConfig({ "/wolfstar": { appLayout: "default", prerender: true, robots: true }, "/blog": { appLayout: "default", prerender: true, robots: true }, "/blog/**": { appLayout: "default", prerender: true, robots: true }, + // Changelog pulls live GitHub releases from ungh.cc, so it revalidates via + // ISR (1 hour) rather than prerendering against the external API at build time. + "/changelog": { appLayout: "default", robots: true, ...getISRConfig(60 * 60) }, }, sourcemap: { @@ -506,6 +514,7 @@ export default defineNuxtConfig({ "https://media.discordapp.net", "https://discord.com", "https://api.iconify.design", + "https://ungh.cc", // Changelog page fetches GitHub releases from ungh.cc on client-side navigation "https://*.netlify.com", "https://*.netlify.app", "https://wolfstar.rocks", // Better Auth's client calls the configured site URL directly (e.g. /api/auth/get-session), not just relative paths diff --git a/package.json b/package.json index da87d7449..bf335a488 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "nuxt-og-image": "6.5.1", "nuxt-security": "^2.5.1", "nuxt-skew-protection": "^1.3.0", + "nuxt-studio": "1.7.0", "nuxt-vitalizer": "^2.0.0", "postcss-nested": "^7.0.2", "simple-git": "^3.32.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ca2be832..0ad8b796a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,7 +146,7 @@ importers: version: 17.4.2 evlog: specifier: ^2.14.0 - version: 2.20.0(@nuxt/kit@4.4.8)(@voidzero-dev/vite-plus-core@0.1.19)(h3@1.15.11)(hono@4.12.28)(nitropack@2.13.4)(ofetch@1.5.1)(react@19.2.7) + version: 2.20.0(@nuxt/kit@4.4.8)(@voidzero-dev/vite-plus-core@0.1.19)(ai@6.0.228)(h3@1.15.11)(hono@4.12.28)(nitropack@2.13.4)(ofetch@1.5.1)(react@19.2.7) feed: specifier: ^5.2.1 version: 5.2.1 @@ -165,6 +165,9 @@ importers: nuxt-skew-protection: specifier: ^1.3.0 version: 1.3.0(@netlify/blobs@10.7.9)(@nuxt/schema@4.4.8)(@nuxtjs/robots@6.1.2)(@voidzero-dev/vite-plus-core@0.1.19)(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(nuxt@4.4.8)(vue@3.5.38) + nuxt-studio: + specifier: 1.7.0 + version: 1.7.0(@netlify/blobs@10.7.9)(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(vue@3.5.38) nuxt-vitalizer: specifier: ^2.0.0 version: 2.0.0(magicast@0.5.3) @@ -306,7 +309,7 @@ importers: version: 1.1.4 skilld: specifier: 1.7.4 - version: 1.7.4(@opentelemetry/api@1.9.1)(magicast@0.5.3)(pg@8.22.0)(playwright@1.61.1)(unstorage@1.17.5)(ws@8.21.0)(zod@4.4.3) + version: 1.7.4(@opentelemetry/api@1.9.1)(ai@6.0.228)(magicast@0.5.3)(pg@8.22.0)(playwright@1.61.1)(unstorage@1.17.5)(ws@8.21.0)(zod@4.4.3) storybook: specifier: catalog:storybook version: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react@19.2.7)(vite-plus@0.1.19) @@ -334,6 +337,28 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@ai-sdk/gateway@3.0.151': + resolution: {integrity: sha512-gsKEm1LleR/xm1FiJjnNxf1ZUKZryj20STsKJB7TdMcaiRqQgeHrdYWv/DNSA8ZBkRahHC+wEYHaI0VR9/EOwQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.39': + resolution: {integrity: sha512-XPR6o7561RYUkfYlLYouWsvm6Gv2tYIQy5pttGRkvML98u138ClPThn5yiQg5rMQutTtZLB+GUC8qFFCzDKQQQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.14': + resolution: {integrity: sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==} + engines: {node: '>=18'} + + '@ai-sdk/vue@3.0.228': + resolution: {integrity: sha512-8+t7DEJt97r7ZmYR641oxr0KLes27tnx3TybYhi/KQm/UUtuq66nhMKealZY6y6Tq3Yg9+gsOgcoCAu13YWtWA==} + engines: {node: '>=18'} + peerDependencies: + vue: 3.5.38 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -3000,6 +3025,9 @@ packages: '@nuxtjs/html-validator@2.1.0': resolution: {integrity: sha512-ldo8ioSsH3OEumtgwDMokTxlhjgO9FxjJWViAxisq5l/wjvaVX8SYTQ02wjtQcQQPSvS6BwgypAp400RlyFHng==} + '@nuxtjs/mdc@0.21.1': + resolution: {integrity: sha512-DIeUD7IahWVUSoZExysxH9dX51Io6hcQYgGJODq0cMTGqaoDD32lRfHBJxYUmy+sUCV1+1hfa2ixspgJgEd2GA==} + '@nuxtjs/mdc@0.22.1': resolution: {integrity: sha512-+tkGhBNxapWZiadZaf9yTxMtnSshjPY2VVnZsy/YoOu+c1p2S6MQgAHB9QWk+FIPT0epJ/VAtGh6qW1tjyPXOg==} @@ -6344,6 +6372,10 @@ packages: engines: {node: '>=20'} hasBin: true + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vite-pwa/assets-generator@1.0.2': resolution: {integrity: sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==} engines: {node: '>=16.14.0'} @@ -6872,6 +6904,12 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ai@6.0.228: + resolution: {integrity: sha512-3TXPF+meV/B0ObVWqLZDfTo0UjT9eKQX+QO5B8n7TSyLxnU3U9FHKxRp7U9mGuTVh7fdo2OtU0J8uGFR4EStsg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-errors@3.0.0: resolution: {integrity: sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==} peerDependencies: @@ -8551,6 +8589,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + evlog@2.20.0: resolution: {integrity: sha512-S4r1+uHKRJg/1AH7LYHjuBAOJpe6Pdf3n9bb1lrIzeNNYzi4dvycrS+regpyriaalx69+IL6vB3OnPN7e7DVLw==} engines: {node: '>=18.0.0'} @@ -9804,6 +9846,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -10872,6 +10917,9 @@ packages: pusher-js: optional: true + nuxt-studio@1.7.0: + resolution: {integrity: sha512-gIRCLPHaj5EcLOX/D1e1ymdJL7dwkxVI5Jrylrv5vIhPWYjC+vggcp71xO4bsC1i9Y1jfbNRbtidVr5wdT0ckg==} + nuxt-vitalizer@2.0.0: resolution: {integrity: sha512-X2mabvcXMfKmWc46p8WmCbTlr8+wDniPHunQIRSGWmH0goqw6YTAe0+ttxRNISGxcobXDqqip7qPCE6+T60t1g==} @@ -12773,6 +12821,11 @@ packages: engines: {node: '>=16'} hasBin: true + swrv@1.2.0: + resolution: {integrity: sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A==} + peerDependencies: + vue: 3.5.38 + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -14026,6 +14079,33 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@ai-sdk/gateway@3.0.151(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@ai-sdk/provider-utils': 4.0.39(zod@4.4.3) + '@vercel/oidc': 3.2.0 + zod: 4.4.3 + + '@ai-sdk/provider-utils@4.0.39(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + zod: 4.4.3 + + '@ai-sdk/provider@3.0.14': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/vue@3.0.228(vue@3.5.38)(zod@4.4.3)': + dependencies: + '@ai-sdk/provider-utils': 4.0.39(zod@4.4.3) + ai: 6.0.228(zod@4.4.3) + swrv: 1.2.0(vue@3.5.38) + vue: 3.5.38(typescript@6.0.3) + transitivePeerDependencies: + - zod + '@alloc/quick-lru@5.2.0': {} '@antfu/install-pkg@1.1.0': @@ -17794,6 +17874,56 @@ snapshots: - magicast - vitest + '@nuxtjs/mdc@0.21.1(magicast@0.5.3)': + dependencies: + '@nuxt/kit': 4.4.8(magicast@0.5.3) + '@shikijs/core': 4.3.1 + '@shikijs/engine-javascript': 4.3.1 + '@shikijs/langs': 4.3.1 + '@shikijs/themes': 4.3.1 + '@shikijs/transformers': 4.3.1 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@vue/compiler-core': 3.5.39 + consola: 3.4.2 + debug: 4.4.3 + defu: 6.1.7 + destr: 2.0.5 + detab: 3.0.2 + github-slugger: 2.0.0 + hast-util-format: 1.1.0 + hast-util-to-mdast: 10.1.2 + hast-util-to-string: 3.0.1 + mdast-util-to-hast: 13.2.1 + micromark-util-sanitize-uri: 2.0.1 + parse5: 8.0.1 + pathe: 2.0.3 + property-information: 7.2.0 + rehype-external-links: 3.0.0 + rehype-minify-whitespace: 6.0.2 + rehype-raw: 7.0.0 + rehype-remark: 10.0.1 + rehype-slug: 6.0.0 + rehype-sort-attribute-values: 5.0.1 + rehype-sort-attributes: 5.0.1 + remark-emoji: 5.0.2 + remark-gfm: 4.0.1 + remark-mdc: 3.11.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remark-stringify: 11.0.0 + scule: 1.3.0 + shiki: 4.3.1 + ufo: 1.6.4 + unified: 11.0.5 + unist-builder: 4.0.0 + unist-util-visit: 5.1.0 + unwasm: 0.5.3 + vfile: 6.0.3 + transitivePeerDependencies: + - magicast + - supports-color + '@nuxtjs/mdc@0.22.1(magicast@0.5.3)': dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) @@ -18075,7 +18205,7 @@ snapshots: '@opentelemetry/api-logs@0.217.0': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@opentelemetry/api@1.9.0': {} @@ -20558,6 +20688,8 @@ snapshots: - rollup - supports-color + '@vercel/oidc@3.2.0': {} + '@vite-pwa/assets-generator@1.0.2': dependencies: cac: 6.7.14 @@ -21175,6 +21307,14 @@ snapshots: agent-base@7.1.4: {} + ai@6.0.228(zod@4.4.3): + dependencies: + '@ai-sdk/gateway': 3.0.151(zod@4.4.3) + '@ai-sdk/provider': 3.0.14 + '@ai-sdk/provider-utils': 4.0.39(zod@4.4.3) + '@opentelemetry/api': 1.9.1 + zod: 4.4.3 + ajv-errors@3.0.0(ajv@8.20.0): dependencies: ajv: 8.20.0 @@ -22888,9 +23028,12 @@ snapshots: events@3.3.0: {} - evlog@2.20.0(@nuxt/kit@4.4.8)(@voidzero-dev/vite-plus-core@0.1.19)(h3@1.15.11)(hono@4.12.28)(nitropack@2.13.4)(ofetch@1.5.1)(react@19.2.7): + eventsource-parser@3.1.0: {} + + evlog@2.20.0(@nuxt/kit@4.4.8)(@voidzero-dev/vite-plus-core@0.1.19)(ai@6.0.228)(h3@1.15.11)(hono@4.12.28)(nitropack@2.13.4)(ofetch@1.5.1)(react@19.2.7): optionalDependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) + ai: 6.0.228(zod@4.4.3) h3: 1.15.11 hono: 4.12.28 nitropack: 2.13.4(@electric-sql/pglite@0.4.1)(@netlify/blobs@10.7.9)(@voidzero-dev/vite-plus-core@0.1.19)(drizzle-orm@0.41.0)(mysql2@3.15.3)(oxc-parser@0.132.0)(rolldown@1.1.4) @@ -24258,6 +24401,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stable-stringify@1.3.0: @@ -26169,6 +26314,50 @@ snapshots: - vite - vue + nuxt-studio@1.7.0(@netlify/blobs@10.7.9)(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(vue@3.5.38): + dependencies: + '@ai-sdk/gateway': 3.0.151(zod@4.4.3) + '@ai-sdk/vue': 3.0.228(vue@3.5.38)(zod@4.4.3) + '@iconify-json/lucide': 1.2.116 + '@nuxtjs/mdc': 0.21.1(magicast@0.5.3) + '@vueuse/core': 14.3.0(vue@3.5.38) + ai: 6.0.228(zod@4.4.3) + defu: 6.1.7 + destr: 2.0.5 + js-yaml: 4.3.0 + minimatch: 10.2.5 + nuxt-component-meta: 0.17.2(magicast@0.5.3) + remark-mdc: 3.11.1 + shiki: 4.3.1 + unstorage: 1.17.5(@netlify/blobs@10.7.9)(db0@0.3.4)(ioredis@5.11.1) + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + optionalDependencies: + ipx: 3.1.1(@netlify/blobs@10.7.9)(db0@0.3.4)(ioredis@5.11.1) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - magicast + - supports-color + - uploadthing + - vue + nuxt-vitalizer@2.0.0(magicast@0.5.3): dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) @@ -28174,9 +28363,10 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - retriv@0.13.2(@huggingface/transformers@4.2.0)(pg@8.22.0)(sqlite-vec@0.1.9)(typescript@6.0.2): + retriv@0.13.2(@huggingface/transformers@4.2.0)(ai@6.0.228)(pg@8.22.0)(sqlite-vec@0.1.9)(typescript@6.0.2): optionalDependencies: '@huggingface/transformers': 4.2.0 + ai: 6.0.228(zod@4.4.3) pg: 8.22.0 sqlite-vec: 0.1.9 typescript: 6.0.2 @@ -28523,7 +28713,7 @@ snapshots: ufo: 1.6.4 vue: 3.5.38(typescript@6.0.3) - skilld@1.7.4(@opentelemetry/api@1.9.1)(magicast@0.5.3)(pg@8.22.0)(playwright@1.61.1)(unstorage@1.17.5)(ws@8.21.0)(zod@4.4.3): + skilld@1.7.4(@opentelemetry/api@1.9.1)(ai@6.0.228)(magicast@0.5.3)(pg@8.22.0)(playwright@1.61.1)(unstorage@1.17.5)(ws@8.21.0)(zod@4.4.3): dependencies: '@clack/prompts': 1.7.0 '@huggingface/transformers': 4.2.0 @@ -28544,14 +28734,14 @@ snapshots: oxc-parser: 0.129.0 p-limit: 7.3.0 pathe: 2.0.3 - retriv: 0.13.2(@huggingface/transformers@4.2.0)(pg@8.22.0)(sqlite-vec@0.1.9)(typescript@6.0.2) + retriv: 0.13.2(@huggingface/transformers@4.2.0)(ai@6.0.228)(pg@8.22.0)(sqlite-vec@0.1.9)(typescript@6.0.2) semver: 7.8.5 sqlite-vec: 0.1.9 std-env: 4.1.0 tinyglobby: 0.2.17 typebox: 1.3.4 typescript: 6.0.2 - unagent: 0.0.8(@huggingface/transformers@4.2.0)(pg@8.22.0)(sqlite-vec@0.1.9)(unstorage@1.17.5) + unagent: 0.0.8(@huggingface/transformers@4.2.0)(ai@6.0.228)(pg@8.22.0)(sqlite-vec@0.1.9)(unstorage@1.17.5) unist-util-visit: 5.1.0 transitivePeerDependencies: - '@ai-sdk/cohere' @@ -28929,6 +29119,10 @@ snapshots: picocolors: 1.1.1 sax: 1.6.0 + swrv@1.2.0(vue@3.5.38): + dependencies: + vue: 3.5.38(typescript@6.0.3) + symbol-tree@3.2.4: {} tagged-tag@1.0.0: {} @@ -29201,7 +29395,7 @@ snapshots: ultrahtml@1.6.0: {} - unagent@0.0.8(@huggingface/transformers@4.2.0)(pg@8.22.0)(sqlite-vec@0.1.9)(unstorage@1.17.5): + unagent@0.0.8(@huggingface/transformers@4.2.0)(ai@6.0.228)(pg@8.22.0)(sqlite-vec@0.1.9)(unstorage@1.17.5): dependencies: croner: 9.1.0 hookable: 6.1.1 @@ -29210,6 +29404,7 @@ snapshots: yaml: 2.9.0 optionalDependencies: '@huggingface/transformers': 4.2.0 + ai: 6.0.228(zod@4.4.3) pg: 8.22.0 sqlite-vec: 0.1.9 unstorage: 1.17.5(@netlify/blobs@10.7.9)(db0@0.3.4)(ioredis@5.11.1) diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 7c34066b8..e112997bc 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -3,6 +3,9 @@ import { AppHeader, AppHeaderAuth, AppLogoMark, + ChangelogContributorMention, + ChangelogContributors, + UApp, CommandsSection, CommandsShowcase, CtaSection, @@ -413,6 +416,55 @@ describe("component accessibility audits", () => { }); }); + describe("ChangelogContributorMention", () => { + it("should have no accessibility violations", async () => { + const component = await mountSuspended({ + components: { ChangelogContributorMention, UApp }, + template: ` + + + + `, + }); + const results = await runAxe(component); + expect(results.violations).toEqual([]); + }); + }); + + describe("ChangelogContributors", () => { + it("should have no accessibility violations", async () => { + const component = await mountSuspended({ + components: { ChangelogContributors, UApp }, + setup() { + return { + contributors: [ + { + name: "RedStar", + username: "RedStar071", + commits: 1847, + hasContributed: true, + avatarSrc: "https://github.com/RedStar071.png", + }, + ], + }; + }, + template: ` + + + + `, + }); + const results = await runAxe(component); + expect(results.violations).toEqual([]); + }); + }); + describe("AppFooter", () => { it("has no axe-core violations", async () => { const wrapper = await mountSuspended(AppFooter); diff --git a/test/nuxt/components/changelog/ContributorMention.spec.ts b/test/nuxt/components/changelog/ContributorMention.spec.ts new file mode 100644 index 000000000..972dab4cf --- /dev/null +++ b/test/nuxt/components/changelog/ContributorMention.spec.ts @@ -0,0 +1,107 @@ +import type { ChangelogContributorItem } from "~/utils/parse-release-contributors"; +import { UApp } from "#components"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import { describe, expect, it } from "vitest"; +import ChangelogContributorMention from "~/components/changelog/ContributorMention.vue"; +import ChangelogContributors from "~/components/changelog/Contributors.vue"; + +const baseProps: ChangelogContributorItem = { + name: "RedStar", + username: "RedStar071", + commits: 1847, + hasContributed: true, + avatarSrc: "https://github.com/RedStar071.png", +}; + +/** UTooltip requires TooltipProvider from UApp. */ +function mountMention(props: ChangelogContributorItem, open = false) { + return mountSuspended({ + components: { ChangelogContributorMention, UApp }, + setup() { + return { props, open: open || undefined }; + }, + template: ` + + + + `, + }); +} + +function mountContributorsList(contributors: ChangelogContributorItem[], idPrefix: string) { + return mountSuspended({ + components: { ChangelogContributors, UApp }, + setup() { + return { contributors, idPrefix }; + }, + template: ` + + + + `, + }); +} + +describe("ChangelogContributorMention", () => { + it("renders the display name and username as a GitHub profile link", async () => { + const component = await mountMention(baseProps); + + const link = component.find('a[href="https://github.com/RedStar071"]'); + expect(link.exists()).toBe(true); + expect(link.text()).toContain("RedStar"); + expect(link.text()).toContain("@RedStar071"); + expect(link.attributes("target")).toBe("_blank"); + expect(link.attributes("rel")).toContain("noopener"); + }); + + it("shows contributor details in the tooltip when open", async () => { + const component = await mountMention(baseProps, true); + + await nextTick(); + + const bodyText = document.body.textContent ?? ""; + expect(bodyText).toContain("RedStar"); + expect(bodyText).toContain("@RedStar071"); + expect(bodyText).toContain("Commits"); + expect(bodyText).toContain("On this repo"); + expect(bodyText).toContain("Contributed here"); + expect(bodyText).toContain("1,847"); + expect(bodyText).toContain("Yes"); + expect(component.exists()).toBe(true); + }); + + it("shows No when hasContributed is false", async () => { + const component = await mountMention( + { + ...baseProps, + commits: 0, + hasContributed: false, + }, + true, + ); + + await nextTick(); + + const bodyText = document.body.textContent ?? ""; + expect(bodyText).toContain("Contributed here"); + expect(bodyText).toContain("No"); + expect(component.exists()).toBe(true); + }); +}); + +describe("ChangelogContributors", () => { + it("renders a unique heading and list of contributor mentions", async () => { + const component = await mountContributorsList([baseProps], "v1.2.3"); + + expect(component.find("#v1-2-3-contributors").exists()).toBe(true); + expect(component.find("#v1-2-3-contributors").text()).toBe("Contributors"); + expect(component.findAll("li")).toHaveLength(1); + expect(component.text()).toContain("RedStar (@RedStar071)"); + }); + + it("renders nothing when the contributors list is empty", async () => { + const component = await mountContributorsList([], "v0.0.1"); + + expect(component.find("section").exists()).toBe(false); + }); +}); diff --git a/test/unit/app/utils/changelog-heading-ids.test.ts b/test/unit/app/utils/changelog-heading-ids.test.ts new file mode 100644 index 000000000..a123c89e0 --- /dev/null +++ b/test/unit/app/utils/changelog-heading-ids.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + prefixHeadingIds, + rehypeChangelogHeadingIds, + slugifyHeadingText, +} from "~/utils/changelog-heading-ids"; + +interface HastNode { + type: string; + tagName?: string; + value?: string; + properties?: Record; + children?: HastNode[]; +} + +function heading(level: number, text: string): HastNode { + return { + type: "element", + tagName: `h${level}`, + properties: {}, + children: [{ type: "text", value: text }], + }; +} + +function tree(...children: HastNode[]): HastNode { + return { type: "root", children }; +} + +describe("slugifyHeadingText", () => { + it("lowercases and joins words with single dashes", () => { + expect(slugifyHeadingText("Bug Fixes")).toBe("bug-fixes"); + }); + + it("strips emoji, punctuation, and leading/trailing dashes", () => { + expect(slugifyHeadingText("❤️ Contributors")).toBe("contributors"); + }); +}); + +describe("prefixHeadingIds", () => { + it("scopes heading ids by the release prefix", () => { + const root = tree(heading(2, "Fixes"), heading(3, "Chore")); + + prefixHeadingIds(root, "v1.2.3"); + + expect(root.children?.[0]?.properties?.id).toBe("v1-2-3-fixes"); + expect(root.children?.[1]?.properties?.id).toBe("v1-2-3-chore"); + }); + + it("keeps ids unique across releases sharing section names", () => { + const releaseA = tree(heading(2, "Fixes")); + const releaseB = tree(heading(2, "Fixes")); + + prefixHeadingIds(releaseA, "v1.0.0"); + prefixHeadingIds(releaseB, "v2.0.0"); + + expect(releaseA.children?.[0]?.properties?.id).toBe("v1-0-0-fixes"); + expect(releaseB.children?.[0]?.properties?.id).toBe("v2-0-0-fixes"); + }); + + it("de-duplicates repeated headings within a single release", () => { + const root = tree(heading(2, "Fixes"), heading(2, "Fixes")); + + prefixHeadingIds(root, "v1.0.0"); + + expect(root.children?.[0]?.properties?.id).toBe("v1-0-0-fixes"); + expect(root.children?.[1]?.properties?.id).toBe("v1-0-0-fixes-1"); + }); + + it("falls back to a section slug when a heading has no text", () => { + const root = tree(heading(2, "")); + + prefixHeadingIds(root, "v1.0.0"); + + expect(root.children?.[0]?.properties?.id).toBe("v1-0-0-section"); + }); +}); + +describe("rehypeChangelogHeadingIds", () => { + it("returns a transformer that prefixes heading ids in place", () => { + const transform = rehypeChangelogHeadingIds({ prefix: "v1.0.0" }); + const root = tree(heading(2, "Enhancements")); + + transform(root); + + expect(root.children?.[0]?.properties?.id).toBe("v1-0-0-enhancements"); + }); +}); diff --git a/test/unit/app/utils/parse-release-contributors.test.ts b/test/unit/app/utils/parse-release-contributors.test.ts new file mode 100644 index 000000000..3f5a0185c --- /dev/null +++ b/test/unit/app/utils/parse-release-contributors.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { parseReleaseContributors } from "~/utils/parse-release-contributors"; + +describe("parseReleaseContributors", () => { + it("returns the original markdown when no Contributors section exists", () => { + const markdown = "## Features\n\n* Something cool\n"; + + expect(parseReleaseContributors(markdown)).toEqual({ + bodyMarkdown: markdown, + contributors: [], + }); + }); + + it("extracts contributors and strips the Contributors section from the body", () => { + const markdown = [ + "### Features", + "", + "* Add hover cards", + "", + "### ❤️ Contributors", + "", + "- RedStar (@RedStar071)", + "- Favna (@favna)", + "", + ].join("\n"); + + const result = parseReleaseContributors(markdown); + + expect(result.contributors).toEqual([ + { name: "RedStar", username: "RedStar071" }, + { name: "Favna", username: "favna" }, + ]); + expect(result.bodyMarkdown).toBe("### Features\n\n* Add hover cards"); + expect(result.bodyMarkdown).not.toContain("Contributors"); + expect(result.bodyMarkdown).not.toContain("@RedStar071"); + }); + + it("matches a Contributors heading without the heart emoji", () => { + const markdown = "### Features\n\n* Fix\n\n### Contributors\n\n- Alice (@alice)\n"; + + const result = parseReleaseContributors(markdown); + + expect(result.contributors).toEqual([{ name: "Alice", username: "alice" }]); + expect(result.bodyMarkdown).toBe("### Features\n\n* Fix"); + }); + + it("keeps content after the Contributors section in the body", () => { + const markdown = [ + "### Features", + "", + "* Thing", + "", + "### ❤️ Contributors", + "", + "- RedStar (@RedStar071)", + "", + "### Notes", + "", + "Extra note", + ].join("\n"); + + const result = parseReleaseContributors(markdown); + + expect(result.contributors).toEqual([{ name: "RedStar", username: "RedStar071" }]); + expect(result.bodyMarkdown).toContain("### Features"); + expect(result.bodyMarkdown).toContain("### Notes"); + expect(result.bodyMarkdown).toContain("Extra note"); + expect(result.bodyMarkdown).not.toContain("Contributors"); + }); + + it("deduplicates usernames case-insensitively", () => { + const markdown = [ + "### ❤️ Contributors", + "", + "- RedStar (@RedStar071)", + "- Other (@redstar071)", + ].join("\n"); + + expect(parseReleaseContributors(markdown).contributors).toEqual([ + { name: "RedStar", username: "RedStar071" }, + ]); + }); + + it("ignores malformed contributor lines", () => { + const markdown = [ + "### ❤️ Contributors", + "", + "- Missing handle", + "- Valid (@valid-user)", + "- (@onlyhandle)", + ].join("\n"); + + expect(parseReleaseContributors(markdown).contributors).toEqual([ + { name: "Valid", username: "valid-user" }, + ]); + }); +}); diff --git a/test/unit/design-tokens/no-hardcoded-colors.test.ts b/test/unit/design-tokens/no-hardcoded-colors.test.ts index 0613595ae..27f195fb5 100644 --- a/test/unit/design-tokens/no-hardcoded-colors.test.ts +++ b/test/unit/design-tokens/no-hardcoded-colors.test.ts @@ -18,6 +18,8 @@ const ROOT = join(import.meta.dirname, "../../.."); const ALLOW_LIST = new Set([ // Satori/Takumi requires resolved static colors (no var() support) "app/components/OgImage/Page.takumi.vue", + "app/components/OgImage/BlogPost.takumi.vue", + "app/components/OgImage/Changelog.takumi.vue", // Discord-domain components maintain Discord brand fidelity with scoped vars "app/components/discord/message.vue", "app/components/discord/message-reply.vue",