feat: DEV-99 blog migration (#3870)

* feat: blog migration w/ fixes

Co-authored-by: Prospector <prospectordev@gmail.com>

* feat: add changelog button to news page

* fix: lint issues

* refactor: replace nuxt content with `@modrinth/blog`

* feat: shared public folder

* feat: try lazy loading html content

* feat: rss + hide newsletter btn + blog.config.ts

* feat: add new chapter modrinth servers post

* fix: lint issues

* fix: only generate RSS feed if changes detected

* fix: utils dep

* fix: lockfile dep

* feat: GET /email/subscribe + subscription button

* fix: lint issues

* feat: articles.json for app

* Made grid more responsive

* fix: changes

* Make margin slightly smaller in lists

* Fix footer link

* feat: latest news

* Fix responsiveness

* Remove old utm link

* Update changelog

* Lint

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
This commit is contained in:
IMB11
2025-07-01 02:59:08 +01:00
committed by GitHub
parent fdb2b1195e
commit eef09e1ffe
220 changed files with 5094 additions and 681 deletions

View File

@@ -7,14 +7,14 @@
{{ formatProjectType(project.project_type).toLowerCase() }}. You may choose one from our
list or provide a custom license. You may also provide a custom URL to your chosen license;
otherwise, the license text will be displayed. See our
<a
href="https://blog.modrinth.com/licensing-guide/"
<nuxt-link
to="/news/article/licensing-guide/"
target="_blank"
rel="noopener"
class="text-link"
>
licensing guide
</a>
</nuxt-link>
for more information.
</p>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -122,8 +122,8 @@
<h3>Creator Monetization Program data</h3>
<p>
When you sign up for our
<a href="https://blog.modrinth.com/p/creator-monetization-beta">
Creator Monetization Program</a
<nuxt-link to="/news/article/creator-monetization-beta">
Creator Monetization Program</nuxt-link
>
(the "CMP"), we collect:
</p>

View File

@@ -0,0 +1,263 @@
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { RssIcon, GitGraphIcon } from "@modrinth/assets";
import dayjs from "dayjs";
import { articles as rawArticles } from "@modrinth/blog";
import { computed } from "vue";
import ShareArticleButtons from "~/components/ui/ShareArticleButtons.vue";
import NewsletterButton from "~/components/ui/NewsletterButton.vue";
const config = useRuntimeConfig();
const route = useRoute();
const rawArticle = rawArticles.find((article) => article.slug === route.params.slug);
if (!rawArticle) {
throw createError({
fatal: true,
statusCode: 404,
message: "The requested article could not be found.",
});
}
const html = await rawArticle.html();
const article = computed(() => ({
...rawArticle,
path: `/news/${rawArticle.slug}`,
thumbnail: rawArticle.thumbnail
? `/news/article/${rawArticle.slug}/thumbnail.webp`
: `/news/default.jpg`,
title: rawArticle.title,
summary: rawArticle.summary,
date: rawArticle.date,
html,
}));
const articleTitle = computed(() => article.value.title);
const articleUrl = computed(() => `https://modrinth.com/news/article/${route.params.slug}`);
const thumbnailPath = computed(() =>
article.value.thumbnail
? `${config.public.siteUrl}${article.value.thumbnail}`
: `${config.public.siteUrl}/news/default.jpg`,
);
const dayjsDate = computed(() => dayjs(article.value.date));
useSeoMeta({
title: () => `${articleTitle.value} - Modrinth News`,
ogTitle: () => articleTitle.value,
description: () => article.value.summary,
ogDescription: () => article.value.summary,
ogType: "article",
ogImage: () => thumbnailPath.value,
articlePublishedTime: () => dayjsDate.value.toISOString(),
twitterCard: "summary_large_image",
twitterImage: () => thumbnailPath.value,
});
</script>
<template>
<div class="page experimental-styles-within py-6">
<div
class="flex flex-wrap items-center justify-between gap-4 border-0 border-b-[1px] border-solid border-divider px-6 pb-6"
>
<nuxt-link :to="`/news`">
<h1 class="m-0 text-3xl font-extrabold hover:underline">News</h1>
</nuxt-link>
<div class="flex gap-2">
<NewsletterButton />
<ButtonStyled circular>
<a v-tooltip="`RSS feed`" aria-label="RSS feed" href="/news/feed/rss.xml" target="_blank">
<RssIcon />
</a>
</ButtonStyled>
<ButtonStyled circular icon-only>
<a v-tooltip="`Changelog`" href="/news/changelog" aria-label="Changelog">
<GitGraphIcon />
</a>
</ButtonStyled>
</div>
</div>
<article class="mt-6 flex flex-col gap-4 px-6">
<h2 class="m-0 text-2xl font-extrabold leading-tight sm:text-4xl">{{ article.title }}</h2>
<p class="m-0 text-base leading-tight sm:text-lg">{{ article.summary }}</p>
<div class="mt-auto text-sm text-secondary sm:text-base">
Posted on {{ dayjsDate.format("MMMM D, YYYY") }}
</div>
<ShareArticleButtons :title="article.title" :url="articleUrl" />
<img
:src="article.thumbnail"
class="aspect-video w-full rounded-xl border-[1px] border-solid border-button-border object-cover sm:rounded-2xl"
:alt="article.title"
/>
<div class="markdown-body" v-html="article.html" />
<h3
class="mb-0 mt-4 border-0 border-t-[1px] border-solid border-divider pt-4 text-base font-extrabold sm:text-lg"
>
Share this article
</h3>
<ShareArticleButtons :title="article.title" :url="articleUrl" />
</article>
</div>
</template>
<style lang="scss" scoped>
.page {
> *:not(.full-width-bg),
> .full-width-bg > * {
max-width: 56rem;
margin-inline: auto;
}
}
.brand-gradient-bg {
background: var(--brand-gradient-bg);
border-color: var(--brand-gradient-border);
}
@media (max-width: 640px) {
.page {
padding-top: 1rem;
padding-bottom: 1rem;
}
article {
gap: 1rem;
}
}
:deep(.markdown-body) {
h1,
h2 {
border-bottom: none;
padding: 0;
}
ul > li:not(:last-child) {
margin-bottom: 0.5rem;
}
ul,
ol {
p {
margin-bottom: 0.5rem;
}
}
ul {
strong {
color: var(--color-contrast);
font-weight: 600;
}
}
h1,
h2,
h3 {
margin-bottom: 0.25rem;
}
h1 {
font-size: 1.5rem;
@media (min-width: 640px) {
font-size: 2rem;
}
}
h2 {
font-size: 1.25rem;
@media (min-width: 640px) {
font-size: 1.5rem;
}
}
h3 {
font-size: 1.125rem;
@media (min-width: 640px) {
font-size: 1.25rem;
}
}
p {
margin-bottom: 1.25rem;
font-size: 0.875rem;
@media (min-width: 640px) {
font-size: 1rem;
}
}
a {
color: var(--color-brand);
font-weight: 600;
&:hover {
text-decoration: underline;
}
}
h1,
h2 {
a {
font-weight: 800;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
a {
color: var(--color-contrast);
}
}
img {
border: 1px solid var(--color-button-border);
border-radius: var(--radius-md);
@media (min-width: 640px) {
border-radius: var(--radius-lg);
}
}
> img,
> :has(img:first-child:last-child) {
display: flex;
justify-content: center;
}
@media (max-width: 640px) {
h1,
h2,
h3,
h4,
h5,
h6 {
margin-bottom: 0.5rem;
}
p {
margin-bottom: 1rem;
}
ul,
ol {
padding-left: 1.25rem;
}
pre {
overflow-x: auto;
font-size: 0.75rem;
}
table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
}
</style>

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { ChevronRightIcon, RssIcon, GitGraphIcon } from "@modrinth/assets";
import dayjs from "dayjs";
import { articles as rawArticles } from "@modrinth/blog";
import { computed, ref } from "vue";
import NewsletterButton from "~/components/ui/NewsletterButton.vue";
import NewsArticleCard from "~/components/ui/news/NewsArticleCard.vue";
const articles = ref(
rawArticles
.map((article) => ({
...article,
path: `/news/article/${article.slug}`,
thumbnail: article.thumbnail
? `/news/article/${article.slug}/thumbnail.webp`
: `/news/default.jpg`,
title: article.title,
summary: article.summary,
date: article.date,
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
);
const featuredArticle = computed(() => articles.value?.[0]);
const config = useRuntimeConfig();
useSeoMeta({
title: "Modrinth News",
ogTitle: "Modrinth News",
description: "Keep up-to-date on the latest news from Modrinth.",
ogDescription: "Keep up-to-date on the latest news from Modrinth.",
ogType: "website",
ogImage: () => `${config.public.siteUrl}/news/thumbnail.jpg`,
twitterCard: "summary_large_image",
twitterImage: () => `${config.public.siteUrl}/news/thumbnail.jpg`,
});
</script>
<template>
<div class="page experimental-styles-within py-6">
<div class="flex flex-wrap items-center justify-between gap-4 px-6">
<div>
<h1 class="m-0 text-3xl font-extrabold">News</h1>
</div>
<div class="flex gap-2">
<NewsletterButton />
<ButtonStyled circular>
<a v-tooltip="`RSS feed`" aria-label="RSS feed" href="/news/feed/rss.xml" target="_blank">
<RssIcon />
</a>
</ButtonStyled>
<ButtonStyled circular icon-only>
<a v-tooltip="`Changelog`" href="/news/changelog" aria-label="Changelog">
<GitGraphIcon />
</a>
</ButtonStyled>
</div>
</div>
<template v-if="articles && articles.length">
<div
v-if="featuredArticle"
class="full-width-bg brand-gradient-bg mt-6 border-0 border-y-[1px] border-solid py-4"
>
<nuxt-link
:to="`${featuredArticle.path}/`"
class="active:scale-[0.99]! group flex transition-all ease-in-out hover:brightness-125"
>
<article class="featured-article px-6">
<div class="featured-image-container">
<img
:src="featuredArticle.thumbnail"
class="aspect-video w-full rounded-2xl border-[1px] border-solid border-button-border object-cover"
/>
</div>
<div class="featured-content">
<p class="m-0 font-bold">Featured article</p>
<h3 class="m-0 text-3xl leading-tight group-hover:underline">
{{ featuredArticle?.title }}
</h3>
<p class="m-0 text-lg leading-tight">{{ featuredArticle?.summary }}</p>
<div class="mt-auto text-secondary">
{{ dayjs(featuredArticle?.date).format("MMMM D, YYYY") }}
</div>
</div>
</article>
</nuxt-link>
</div>
<div class="mt-6 px-6">
<div class="group flex w-fit items-center gap-1">
<h2 class="m-0 text-xl font-extrabold">More articles</h2>
<ChevronRightIcon
v-if="false"
class="ml-0 h-6 w-6 transition-all group-hover:ml-1 group-hover:text-brand"
/>
</div>
<div class="mt-4 grid grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-4">
<NewsArticleCard v-for="article in articles" :key="article.path" :article="article" />
</div>
</div>
</template>
<div v-else class="pt-4">Error: Articles could not be loaded.</div>
</div>
</template>
<style lang="scss" scoped>
.page {
> *:not(.full-width-bg),
> .full-width-bg > * {
max-width: 56rem;
margin-inline: auto;
}
}
.brand-gradient-bg {
background: var(--brand-gradient-bg);
border-color: var(--brand-gradient-border);
}
.featured-article {
display: flex;
flex-wrap: wrap;
gap: 1rem;
width: 100%;
}
.featured-image-container {
flex: 1;
min-width: 0;
}
.featured-content {
flex: 1;
min-width: 16rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (max-width: 640px) {
.featured-article {
flex-direction: column;
}
.featured-image-container {
order: 1;
}
.featured-content {
order: 2;
min-width: 0;
}
}
</style>