Add Nuxt frontend with blog index, article and category pages

The browser never talks to Strapi: pages call Nitro endpoints under
server/api/, which are the only place Strapi queries are built. That keeps the
Strapi URL internal to the Docker network and avoids CORS entirely.

Article bodies are rendered from Markdown to HTML in the endpoint, so the
content is server-rendered and crawlable, marked stays out of the client
bundle, and the meta description is derived from the opening of the body.

Pages carry canonical URLs, Open Graph tags and BlogPosting structured data,
and handle empty, missing and failed states.
This commit is contained in:
2026-08-25 11:42:17 +02:00
parent ec5976135f
commit a808f1010f
28 changed files with 10387 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
.nuxt
.output
.data
.env
.git
+24
View File
@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
+14
View File
@@ -0,0 +1,14 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.output /app/.output
USER node
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
+75
View File
@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+8
View File
@@ -0,0 +1,8 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
:root {
--color-text: #16181d;
--color-muted: #5b6270;
--color-bg: #ffffff;
--color-surface: #f5f6f8;
--color-border: #e2e5ea;
--color-accent: #2f4bd8;
--radius: 10px;
--width: 68rem;
--space: 1.25rem;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--color-text);
background: var(--color-bg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 1.05rem;
line-height: 1.65;
}
img {
max-width: 100%;
height: auto;
}
a {
color: var(--color-accent);
}
h1,
h2,
h3 {
line-height: 1.25;
text-wrap: balance;
}
:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
.container {
width: 100%;
max-width: var(--width);
margin-inline: auto;
padding-inline: var(--space);
}
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: var(--space);
top: var(--space);
z-index: 10;
padding: 0.5rem 0.75rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
}
.muted {
color: var(--color-muted);
}
.card-grid {
display: grid;
gap: 1.75rem;
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
padding: 0;
margin: 0;
list-style: none;
}
.prose {
max-width: 44rem;
}
.prose img {
border-radius: var(--radius);
}
.prose pre {
overflow-x: auto;
padding: 1rem;
background: var(--color-surface);
border-radius: var(--radius);
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0;
margin: 2rem 0 0;
list-style: none;
}
.tag-list a {
display: inline-block;
padding: 0.15rem 0.6rem;
font-size: 0.9rem;
background: var(--color-surface);
border-radius: 999px;
text-decoration: none;
}
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { ArticleSummary } from '#shared/types/blog'
const props = defineProps<{ article: ArticleSummary }>()
const mediaUrl = useMediaUrl()
const cover = computed(() => mediaUrl(props.article.cover))
</script>
<template>
<article class="card">
<img
v-if="cover"
class="cover"
:src="cover"
:alt="article.cover?.alternativeText ?? ''"
:width="article.cover?.width ?? undefined"
:height="article.cover?.height ?? undefined"
loading="lazy"
>
<p v-if="article.category" class="muted meta">
{{ article.category.name }}
</p>
<h2>
<NuxtLink :to="`/blog/${article.slug}`">{{ article.title }}</NuxtLink>
</h2>
<p class="muted meta">
<time :datetime="isoDate(article.publishedAt)">{{ formatDate(article.publishedAt) }}</time>
</p>
</article>
</template>
<style scoped>
.card h2 {
margin: 0.35rem 0 0.5rem;
font-size: 1.2rem;
}
.card h2 a {
color: inherit;
text-decoration: none;
}
.card h2 a:hover {
text-decoration: underline;
}
.card p {
margin: 0 0 0.5rem;
}
.cover {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: var(--radius);
background: var(--color-surface);
}
.meta {
font-size: 0.9rem;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
import type { StrapiImage } from '#shared/types/blog'
/**
* Strapi returns media paths relative to its own origin; the browser needs
* absolute ones. Remote providers (S3) already return absolute URLs.
*/
export function useMediaUrl() {
const { public: config } = useRuntimeConfig()
return (image: StrapiImage | null | undefined): string | null => {
if (!image?.url) return null
return image.url.startsWith('http') ? image.url : `${config.strapiUrl}${image.url}`
}
}
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps<{ error: NuxtError }>()
const isNotFound = computed(() => props.error.statusCode === 404)
useSeoMeta({ robots: 'noindex' })
</script>
<template>
<NuxtLayout>
<h1>{{ isNotFound ? 'Pagina non trovata' : 'Qualcosa è andato storto' }}</h1>
<p class="muted">
{{
isNotFound
? 'Il contenuto che cerchi non esiste o è stato spostato.'
: 'Riprova tra qualche istante.'
}}
</p>
<p>
<NuxtLink to="/">Torna alla home</NuxtLink>
</p>
</NuxtLayout>
</template>
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import type { Category } from '#shared/types/blog'
const { data: categories } = await useFetch<Category[]>('/api/categories', {
key: 'nav-categories',
default: () => [],
})
</script>
<template>
<div class="page">
<a class="skip-link" href="#main">Salta al contenuto</a>
<header class="site-header">
<div class="container header-inner">
<NuxtLink to="/" class="brand">Blog</NuxtLink>
<nav aria-label="Navigazione principale">
<ul>
<li><NuxtLink to="/blog">Articoli</NuxtLink></li>
<li v-for="category in categories" :key="category.slug">
<NuxtLink :to="`/category/${category.slug}`">{{ category.name }}</NuxtLink>
</li>
</ul>
</nav>
</div>
</header>
<main id="main" class="container site-main">
<slot />
</main>
<footer class="site-footer">
<div class="container">
<p class="muted">&copy; {{ new Date().getFullYear() }} Blog</p>
</div>
</footer>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.site-header {
border-bottom: 1px solid var(--color-border);
}
.header-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem 2rem;
padding-block: 1rem;
}
.brand {
font-size: 1.25rem;
font-weight: 700;
color: inherit;
text-decoration: none;
}
nav ul {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0;
margin: 0;
list-style: none;
}
nav a {
color: inherit;
text-decoration: none;
}
nav a:hover,
nav a.router-link-active {
text-decoration: underline;
}
.site-main {
flex: 1;
padding-block: 2.5rem 4rem;
}
.site-footer {
padding-block: 1.5rem;
border-top: 1px solid var(--color-border);
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import type { Article } from '#shared/types/blog'
const route = useRoute()
const slug = route.params.slug as string
const { data: article } = await useFetch<Article>(`/api/articles/${slug}`)
if (!article.value) {
throw createError({ statusCode: 404, statusMessage: 'Articolo non trovato', fatal: true })
}
const { public: config } = useRuntimeConfig()
const mediaUrl = useMediaUrl()
const canonical = `${config.siteUrl}/blog/${article.value.slug}`
const cover = computed(() => mediaUrl(article.value?.cover) ?? undefined)
useSeoMeta({
title: article.value.title,
description: article.value.summary,
ogTitle: article.value.title,
ogDescription: article.value.summary,
ogType: 'article',
ogUrl: canonical,
ogImage: cover,
twitterCard: 'summary_large_image',
})
useHead({
link: [{ rel: 'canonical', href: canonical }],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: article.value.title,
description: article.value.summary,
datePublished: isoDate(article.value.publishedAt),
image: cover.value ? [cover.value] : undefined,
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
}),
},
],
})
</script>
<template>
<article v-if="article">
<nav class="breadcrumb muted" aria-label="Percorso">
<NuxtLink to="/blog">Articoli</NuxtLink>
<template v-if="article.category">
<span aria-hidden="true"> / </span>
<NuxtLink :to="`/category/${article.category.slug}`">{{ article.category.name }}</NuxtLink>
</template>
</nav>
<h1>{{ article.title }}</h1>
<p class="muted meta">
<time :datetime="isoDate(article.publishedAt)">{{ formatDate(article.publishedAt) }}</time>
</p>
<img
v-if="cover"
class="cover"
:src="cover"
:alt="article.cover?.alternativeText ?? ''"
:width="article.cover?.width ?? undefined"
:height="article.cover?.height ?? undefined"
>
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
<div class="prose" v-html="article.html" />
</article>
</template>
<style scoped>
article {
max-width: 44rem;
}
.breadcrumb {
font-size: 0.9rem;
}
h1 {
margin: 0.5rem 0 0.25rem;
font-size: clamp(1.8rem, 4vw, 2.6rem);
}
.meta {
margin-top: 0;
}
.cover {
width: 100%;
margin-block: 1.5rem;
border-radius: var(--radius);
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { ArticleSummary, Paginated } from '#shared/types/blog'
const route = useRoute()
const page = computed(() => {
const value = Number(route.query.page)
return Number.isInteger(value) && value > 0 ? value : 1
})
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
query: { page },
})
const { public: config } = useRuntimeConfig()
const canonical = computed(
() => `${config.siteUrl}/blog${page.value > 1 ? `?page=${page.value}` : ''}`
)
useSeoMeta({
title: page.value > 1 ? `Articoli — pagina ${page.value}` : 'Articoli',
description: 'Tutti gli articoli pubblicati sul blog.',
ogType: 'website',
})
useHead({ link: [{ rel: 'canonical', href: canonical }] })
</script>
<template>
<div>
<h1>Articoli</h1>
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
<p v-else-if="status === 'pending'">Caricamento</p>
<p v-else-if="!data?.items.length">Non ci sono ancora articoli pubblicati.</p>
<template v-else>
<ul class="card-grid">
<li v-for="article in data.items" :key="article.slug">
<ArticleCard :article="article" />
</li>
</ul>
<nav v-if="data.pageCount > 1" class="pagination" aria-label="Paginazione">
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
Pagina precedente
</NuxtLink>
<span class="muted">Pagina {{ data.page }} di {{ data.pageCount }}</span>
<NuxtLink
v-if="data.page < data.pageCount"
:to="{ query: { page: data.page + 1 } }"
rel="next"
>
Pagina successiva
</NuxtLink>
</nav>
</template>
</div>
</template>
<style scoped>
.pagination {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: center;
margin-top: 3rem;
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import type { ArticleSummary, Paginated, Category } from '#shared/types/blog'
const route = useRoute()
const slug = route.params.slug as string
const page = computed(() => {
const value = Number(route.query.page)
return Number.isInteger(value) && value > 0 ? value : 1
})
const { data: category } = await useFetch<Category>(`/api/categories/${slug}`)
if (!category.value) {
throw createError({ statusCode: 404, statusMessage: 'Categoria non trovata', fatal: true })
}
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
query: { page, category: slug },
})
const { public: config } = useRuntimeConfig()
const canonical = computed(
() => `${config.siteUrl}/category/${slug}${page.value > 1 ? `?page=${page.value}` : ''}`
)
useSeoMeta({
title: `${category.value.name} — Articoli`,
description: `Tutti gli articoli nella categoria ${category.value.name}.`,
ogType: 'website',
})
useHead({ link: [{ rel: 'canonical', href: canonical }] })
</script>
<template>
<div>
<p class="breadcrumb muted">
<NuxtLink to="/blog">Articoli</NuxtLink>
</p>
<h1>{{ category?.name }}</h1>
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
<p v-else-if="!data?.items.length">Non ci sono articoli in questa categoria.</p>
<template v-else>
<ul class="card-grid">
<li v-for="article in data.items" :key="article.slug">
<ArticleCard :article="article" />
</li>
</ul>
<nav v-if="data.pageCount > 1" class="pagination" aria-label="Paginazione">
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
Pagina precedente
</NuxtLink>
<span class="muted">Pagina {{ data.page }} di {{ data.pageCount }}</span>
<NuxtLink
v-if="data.page < data.pageCount"
:to="{ query: { page: data.page + 1 } }"
rel="next"
>
Pagina successiva
</NuxtLink>
</nav>
</template>
</div>
</template>
<style scoped>
.breadcrumb {
font-size: 0.9rem;
}
.pagination {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: center;
margin-top: 3rem;
}
</style>
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { ArticleSummary, Paginated } from '#shared/types/blog'
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
key: 'home-articles',
query: { page: 1 },
})
const articles = computed(() => data.value?.items.slice(0, 6) ?? [])
const { public: config } = useRuntimeConfig()
useSeoMeta({
title: 'Blog',
description: 'Articoli, guide e approfondimenti.',
ogTitle: 'Blog',
ogDescription: 'Articoli, guide e approfondimenti.',
ogType: 'website',
ogUrl: config.siteUrl,
})
useHead({ link: [{ rel: 'canonical', href: config.siteUrl }] })
</script>
<template>
<div>
<section class="hero">
<h1>Blog</h1>
<p class="muted">Articoli, guide e approfondimenti.</p>
</section>
<h2>Ultimi articoli</h2>
<p v-if="error">
Non è stato possibile caricare gli articoli. Riprova più tardi.
</p>
<p v-else-if="!articles.length">
Non ci sono ancora articoli pubblicati.
</p>
<ul v-else class="card-grid">
<li v-for="article in articles" :key="article.slug">
<ArticleCard :article="article" />
</li>
</ul>
<p class="more">
<NuxtLink to="/blog">Vedi tutti gli articoli</NuxtLink>
</p>
</div>
</template>
<style scoped>
.hero {
margin-bottom: 3rem;
}
.hero h1 {
margin-bottom: 0.25rem;
font-size: clamp(2rem, 5vw, 3rem);
}
.more {
margin-top: 2.5rem;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
css: ['~/assets/css/main.css'],
app: {
head: {
htmlAttrs: { lang: 'it' },
meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }],
},
},
runtimeConfig: {
// Server-only: internal Strapi address, never exposed to the browser.
strapiUrl: 'http://localhost:1337',
strapiToken: '',
public: {
// Canonical origin of the public website.
siteUrl: 'http://localhost:3000',
// Browser-reachable Strapi origin, used to build absolute media URLs.
strapiUrl: 'http://localhost:1337',
},
},
typescript: {
strict: true,
},
})
+9342
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "frontend",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"marked": "^18.0.11",
"nuxt": "^4.5.2",
"vue": "^3.5.41",
"vue-router": "^5.2.0"
},
"devDependencies": {
"typescript": "^5.9.3",
"vue-tsc": "^3.3.11"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
@@ -0,0 +1,24 @@
import type { Article } from '#shared/types/blog'
type RawArticle = Omit<Article, 'html' | 'summary'> & { content: string | null }
export default defineEventHandler(async (event): Promise<Article> => {
const slug = getRouterParam(event, 'slug')
if (!slug) {
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
}
const response = await strapiFetch<StrapiList<RawArticle>>(
`/api/articles?${ARTICLE_DETAIL_QUERY}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
)
const article = response.data[0]
if (!article) {
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
}
const { content, ...rest } = article
return { ...rest, html: renderMarkdown(content), summary: summarise(content) }
})
+22
View File
@@ -0,0 +1,22 @@
import type { ArticleSummary, Paginated } from '#shared/types/blog'
export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => {
const query = getQuery(event)
const page = pageParam(query.page)
const category = typeof query.category === 'string' ? query.category : null
const filter = category
? `&filters[category][slug][$eq]=${encodeURIComponent(category)}`
: ''
const response = await strapiFetch<StrapiList<ArticleSummary>>(
`/api/articles?${ARTICLE_SUMMARY_QUERY}&${pagination(page)}${filter}`
)
return {
items: response.data,
page: response.meta.pagination.page,
pageCount: response.meta.pagination.pageCount,
total: response.meta.pagination.total,
}
})
@@ -0,0 +1,21 @@
import type { Category } from '#shared/types/blog'
export default defineEventHandler(async (event): Promise<Category> => {
const slug = getRouterParam(event, 'slug')
if (!slug) {
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
}
const response = await strapiFetch<StrapiList<Category>>(
`/api/categories?fields[0]=name&fields[1]=slug&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
)
const category = response.data[0]
if (!category) {
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
}
return category
})
@@ -0,0 +1,9 @@
import type { Category } from '#shared/types/blog'
export default defineEventHandler(async (): Promise<Category[]> => {
const response = await strapiFetch<StrapiList<Category>>(
'/api/categories?fields[0]=name&fields[1]=slug&sort[0]=name:asc&pagination[pageSize]=100'
)
return response.data
})
+30
View File
@@ -0,0 +1,30 @@
/** Strapi query fragments. Only the fields the pages actually render are requested. */
const list = (prefix: string, names: readonly string[]) =>
names.map((name, i) => `${prefix}[${i}]=${name}`).join('&')
const IMAGE_FIELDS = ['url', 'alternativeText', 'width', 'height'] as const
export const ARTICLE_SUMMARY_QUERY = [
list('fields', ['title', 'slug', 'publishedAt']),
list('populate[cover][fields]', IMAGE_FIELDS),
list('populate[category][fields]', ['name', 'slug']),
'sort[0]=publishedAt:desc',
].join('&')
export const ARTICLE_DETAIL_QUERY = [
list('fields', ['title', 'slug', 'content', 'publishedAt']),
list('populate[cover][fields]', IMAGE_FIELDS),
list('populate[category][fields]', ['name', 'slug']),
].join('&')
export const PAGE_SIZE = 12
export const pagination = (page: number, pageSize = PAGE_SIZE) =>
`pagination[page]=${page}&pagination[pageSize]=${pageSize}`
/** Reads a positive integer query param, falling back to 1. */
export const pageParam = (value: unknown): number => {
const page = Number(value)
return Number.isInteger(page) && page > 0 ? page : 1
}
+54
View File
@@ -0,0 +1,54 @@
import { marked } from 'marked'
/**
* Calls the Strapi REST API from the server only, so the internal URL and any
* future API token never reach the browser.
*/
export async function strapiFetch<T>(path: string): Promise<T> {
const { strapiUrl, strapiToken } = useRuntimeConfig()
try {
return (await $fetch(path, {
baseURL: strapiUrl,
headers: strapiToken ? { Authorization: `Bearer ${strapiToken}` } : undefined,
})) as T
} catch (error) {
console.error(`Strapi request failed: ${path}`, error)
throw createError({ statusCode: 502, statusMessage: 'Content service unavailable' })
}
}
/**
* Renders the Markdown body server-side, so the HTML is crawlable and `marked`
* stays out of the client bundle.
*
* ponytail: no HTML sanitisation — article bodies come from authenticated
* editors only. Add DOMPurify here if authoring is ever opened up further.
*/
export function renderMarkdown(source: string | null | undefined): string {
return source ? (marked.parse(source, { async: false }) as string) : ''
}
/**
* There is no excerpt field: the meta description is the opening of the body,
* stripped of Markdown syntax and clipped on a word boundary.
*/
export function summarise(source: string | null | undefined, maxLength = 155): string {
const text = (source ?? '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[#>*_`~-]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (text.length <= maxLength) return text
const clipped = text.slice(0, maxLength)
return `${clipped.slice(0, clipped.lastIndexOf(' ')).trimEnd()}`
}
export interface StrapiList<T> {
data: T[]
meta: { pagination: { page: number; pageCount: number; total: number } }
}
+36
View File
@@ -0,0 +1,36 @@
/** Shape of the Strapi payloads, narrowed to what the site actually renders. */
export interface StrapiImage {
url: string
alternativeText: string | null
width: number | null
height: number | null
}
export interface Category {
name: string
slug: string
}
/** An article as returned by the listing endpoints. */
export interface ArticleSummary {
title: string
slug: string
publishedAt: string
cover: StrapiImage | null
category: Category | null
}
/** A single article, with the body already rendered to HTML server-side. */
export interface Article extends ArticleSummary {
html: string
/** Plain-text opening of the body, used as the meta description. */
summary: string
}
export interface Paginated<T> {
items: T[]
page: number
pageCount: number
total: number
}
+10
View File
@@ -0,0 +1,10 @@
/** Human-readable date for display; returns an empty string when unset. */
export function formatDate(value: string | null | undefined): string {
if (!value) return ''
return new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(value))
}
/** Machine-readable date for the `datetime` attribute and structured data. */
export function isoDate(value: string | null | undefined): string {
return value ? new Date(value).toISOString() : ''
}
+18
View File
@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}