API pubblica articoli
Endpoint REST in sola lettura. CORS aperto. Restituisce solo articoli pubblicati. Il contenuto è HTML pronto per essere renderizzato (grassetti, titoli, immagini, tabelle, link).
Lista articoli (paginata)
GET https://your-domain.com/api/public/articles?page=1&limit=10&search=tarocchiParametri: page (default 1), limit (1-100, default 10), search (opzionale).
Singolo articolo per slug
GET https://your-domain.com/api/public/articles/{slug}{
"data": [
{
"id": "uuid",
"slug": "tarocchi-amore",
"title": "Tarocchi e amore",
"excerpt": "Breve riassunto...",
"content": "<p>HTML completo</p>",
"featured_image_url": "https://.../immagine.jpg",
"meta_title": "Tarocchi e amore | Cartomanzia delle Fate",
"meta_description": "Scopri come...",
"published_at": "2025-03-12T10:00:00Z"
}
],
"page": 1,
"limit": 10,
"total": 122
}src/app/services/articles.service.ts// src/app/services/articles.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface Article {
id: string;
slug: string;
title: string;
excerpt: string | null;
content: string | null; // HTML completo (con grassetti, immagini, titoli...)
featured_image_url: string | null;
meta_title: string | null;
meta_description: string | null;
published_at: string | null; // ISO date
}
export interface ArticleListResponse {
data: Article[];
page: number;
limit: number;
total: number;
}
@Injectable({ providedIn: 'root' })
export class ArticlesService {
private http = inject(HttpClient);
private base = 'https://your-domain.com/api/public/articles';
list(page = 1, limit = 10, search?: string): Observable<ArticleListResponse> {
let params = new HttpParams()
.set('page', String(page))
.set('limit', String(limit));
if (search) params = params.set('search', search);
return this.http.get<ArticleListResponse>(this.base, { params });
}
bySlug(slug: string): Observable<Article> {
return this.http.get<Article>(`${this.base}/${slug}`);
}
}// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'top' })),
provideHttpClient(),
],
};?page=N nella URL, rel="prev" / rel="next" per il SEO.// src/app/pages/blog-list.component.ts — CON PAGINAZIONE
import { Component, inject, signal, computed } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ArticlesService, Article } from '../services/articles.service';
import { Title, Meta } from '@angular/platform-browser';
const PAGE_SIZE = 10;
const SITE_URL = 'https://www.cartomanziadellefate.it';
@Component({
standalone: true,
imports: [RouterLink],
template: `
<h1>Blog</h1>
@for (a of articles(); track a.id) {
<article class="post-card">
@if (a.featured_image_url) {
<img [src]="a.featured_image_url" [alt]="a.title" loading="lazy" />
}
<h2><a [routerLink]="['/blog', a.slug]">{{ a.title }}</a></h2>
<p>{{ a.excerpt }}</p>
</article>
}
<!-- Paginazione -->
<nav class="pagination" aria-label="Paginazione articoli">
<button (click)="goTo(page() - 1)" [disabled]="page() <= 1">‹ Precedente</button>
<span>Pagina {{ page() }} di {{ totalPages() }}</span>
<button (click)="goTo(page() + 1)" [disabled]="page() >= totalPages()">Successiva ›</button>
</nav>
`,
})
export class BlogListComponent {
private svc = inject(ArticlesService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private title = inject(Title);
private meta = inject(Meta);
articles = signal<Article[]>([]);
page = signal(1);
total = signal(0);
totalPages = computed(() => Math.max(1, Math.ceil(this.total() / PAGE_SIZE)));
ngOnInit() {
// Sincronizza pagina con la URL: /blog?page=2
this.route.queryParamMap.subscribe(qp => {
const p = Math.max(1, parseInt(qp.get('page') ?? '1', 10) || 1);
this.page.set(p);
this.load(p);
});
}
private load(p: number) {
this.svc.list(p, PAGE_SIZE).subscribe(res => {
this.articles.set(res.data);
this.total.set(res.total);
// SEO: titolo, descrizione, canonical, rel=prev/next
const suffix = p > 1 ? ` — Pagina ${p}` : '';
this.title.setTitle(`Blog${suffix} — Cartomanzia delle Fate`);
this.meta.updateTag({ name: 'description', content: 'Articoli e guide di cartomanzia' });
this.setCanonical(p === 1 ? `${SITE_URL}/blog` : `${SITE_URL}/blog?page=${p}`);
this.setRel('prev', p > 1 ? `${SITE_URL}/blog${p - 1 > 1 ? '?page=' + (p - 1) : ''}` : null);
this.setRel('next', p < this.totalPages() ? `${SITE_URL}/blog?page=${p + 1}` : null);
});
}
goTo(p: number) {
this.router.navigate([], { queryParams: { page: p === 1 ? null : p }, queryParamsHandling: 'merge' });
}
private setCanonical(href: string) {
let link = document.querySelector("link[rel='canonical']") as HTMLLinkElement | null;
if (!link) { link = document.createElement('link'); link.rel = 'canonical'; document.head.appendChild(link); }
link.href = href;
}
private setRel(rel: 'prev' | 'next', href: string | null) {
let link = document.querySelector(`link[rel='${rel}']`) as HTMLLinkElement | null;
if (href === null) { link?.remove(); return; }
if (!link) { link = document.createElement('link'); link.rel = rel; document.head.appendChild(link); }
link.href = href;
}
}Article — tutto popolato dinamicamente.// src/app/pages/blog-detail.component.ts — CON SEO COMPLETO
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ArticlesService, Article } from '../services/articles.service';
import { Title, Meta, DomSanitizer, SafeHtml } from '@angular/platform-browser';
const SITE_URL = 'https://www.cartomanziadellefate.it';
const SITE_NAME = 'Cartomanzia delle Fate';
@Component({
standalone: true,
template: `
@if (article(); as a) {
<article>
@if (a.featured_image_url) {
<img [src]="a.featured_image_url" [alt]="a.title" />
}
<h1>{{ a.title }}</h1>
<time *ngIf="a.published_at" [attr.datetime]="a.published_at">
{{ a.published_at | date:'longDate':'':'it' }}
</time>
<div class="post-content" [innerHTML]="content()"></div>
</article>
}
`,
})
export class BlogDetailComponent {
private route = inject(ActivatedRoute);
private svc = inject(ArticlesService);
private title = inject(Title);
private meta = inject(Meta);
private sanitizer = inject(DomSanitizer);
article = signal<Article | null>(null);
content = signal<SafeHtml>('');
ngOnInit() {
const slug = this.route.snapshot.paramMap.get('slug')!;
this.svc.bySlug(slug).subscribe(a => {
this.article.set(a);
this.content.set(this.sanitizer.bypassSecurityTrustHtml(a.content ?? ''));
const url = `${SITE_URL}/blog/${a.slug}`;
const pageTitle = a.meta_title ?? a.title;
const desc = a.meta_description ?? a.excerpt ?? '';
// 1) <title> e meta description
this.title.setTitle(pageTitle);
this.meta.updateTag({ name: 'description', content: desc });
// 2) Canonical
this.setCanonical(url);
// 3) Open Graph
this.meta.updateTag({ property: 'og:title', content: pageTitle });
this.meta.updateTag({ property: 'og:description', content: desc });
this.meta.updateTag({ property: 'og:type', content: 'article' });
this.meta.updateTag({ property: 'og:url', content: url });
this.meta.updateTag({ property: 'og:site_name', content: SITE_NAME });
if (a.featured_image_url) {
this.meta.updateTag({ property: 'og:image', content: a.featured_image_url });
}
// 4) Twitter Card
this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' });
this.meta.updateTag({ name: 'twitter:title', content: pageTitle });
this.meta.updateTag({ name: 'twitter:description', content: desc });
if (a.featured_image_url) {
this.meta.updateTag({ name: 'twitter:image', content: a.featured_image_url });
}
// 5) JSON-LD Article (structured data)
this.injectJsonLd({
'@context': 'https://schema.org',
'@type': 'Article',
headline: a.title,
description: desc,
image: a.featured_image_url ?? undefined,
datePublished: a.published_at,
author: { '@type': 'Organization', name: SITE_NAME },
publisher: { '@type': 'Organization', name: SITE_NAME },
mainEntityOfPage: { '@type': 'WebPage', '@id': url },
});
});
}
private setCanonical(href: string) {
let link = document.querySelector("link[rel='canonical']") as HTMLLinkElement | null;
if (!link) { link = document.createElement('link'); link.rel = 'canonical'; document.head.appendChild(link); }
link.href = href;
}
private injectJsonLd(obj: unknown) {
document.querySelectorAll('script[data-jsonld="article"]').forEach(s => s.remove());
const s = document.createElement('script');
s.type = 'application/ld+json';
s.setAttribute('data-jsonld', 'article');
s.textContent = JSON.stringify(obj);
document.head.appendChild(s);
}
}// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { BlogListComponent } from './pages/blog-list.component';
import { BlogDetailComponent } from './pages/blog-detail.component';
export const routes: Routes = [
{ path: 'blog', component: BlogListComponent },
{ path: 'blog/:slug', component: BlogDetailComponent },
];styles.css per stilizzarlo lato Angular./* Stili minimi per il contenuto generato dall'editor */
.post-content {
line-height: 1.7;
color: #1a1a1a;
}
.post-content h2 { font-size: 1.6rem; margin: 2rem 0 1rem; font-weight: 700; }
.post-content h3 { font-size: 1.3rem; margin: 1.5rem 0 0.75rem; font-weight: 700; }
.post-content h4 { font-size: 1.1rem; margin: 1.25rem 0 0.5rem; font-weight: 600; }
.post-content p { margin: 0 0 1rem; }
.post-content ul, .post-content ol { margin: 0 0 1rem 1.5rem; }
.post-content li { margin: 0.25rem 0; }
.post-content blockquote {
border-left: 4px solid #ccc;
margin: 1.5rem 0;
padding: 0.5rem 1rem;
color: #555;
font-style: italic;
}
.post-content a { color: #2563eb; text-decoration: underline; }
.post-content img {
max-width: 100%;
height: auto;
border-radius: 6px;
margin: 1rem 0;
}
.post-content table {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
}
.post-content th, .post-content td {
border: 1px solid #ddd;
padding: 0.5rem 0.75rem;
}
.post-content th { background: #f5f5f5; font-weight: 600; }
.post-content code {
background: #f3f4f6;
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.9em;
}
.post-content pre {
background: #1f2937;
color: #f9fafb;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
}
/* Allineamento testo dall'editor */
.post-content [style*="text-align: center"] { text-align: center; }
.post-content [style*="text-align: right"] { text-align: right; }
.post-content [style*="text-align: justify"] { text-align: justify; }// scripts/generate-sitemap.ts — esegui in build
// npx ts-node scripts/generate-sitemap.ts > src/sitemap.xml
const API = 'https://your-domain.com/api/public/articles';
const SITE = 'https://www.cartomanziadellefate.it';
async function main() {
// Recupera TUTTI gli articoli (paginando)
const all: { slug: string; published_at: string | null }[] = [];
let page = 1;
while (true) {
const res = await fetch(`${API}?page=${page}&limit=100`);
const json = await res.json();
all.push(...json.data);
if (page * json.limit >= json.total) break;
page++;
}
const urls = [
` <url><loc>${SITE}/</loc><priority>1.0</priority></url>`,
` <url><loc>${SITE}/blog</loc><priority>0.9</priority></url>`,
...all.map(a => ` <url>
<loc>${SITE}/blog/${a.slug}</loc>
${a.published_at ? `<lastmod>${a.published_at}</lastmod>` : ''}
<priority>0.7</priority>
</url>`),
];
console.log(`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`);
}
main();Sottodominio del pannello CMS
Pubblica questo pannello su admin.cartomanziadellefate.it. È area privata: il tag noindex, nofollow è già impostato, quindi Google non lo indicizza.
Sito Angular sul dominio principale
Il blog deve vivere su www.cartomanziadellefate.it/blog/... per non disperdere l'authority SEO del dominio. Evita sottodomini tipo articoli. per contenuti pubblici: Google li tratta come sito separato.
CORS dell'API
Attualmente accetta *. Quando vai in produzione possiamo restringere a https://www.cartomanziadellefate.it per maggior sicurezza.
Angular SSR (importante)
Per indicizzazione ottimale considera Angular Universal / SSR: i meta tag iniettati lato client funzionano con Googlebot moderno ma SSR è più affidabile per social preview (Facebook, LinkedIn, WhatsApp) che NON eseguono JavaScript.
URL coerenti
Lo slug coincide con quello WordPress originale, quindi gli URL /blog/nome-articolo restano invariati e non servono redirect.