portfolio-seo
**Scope**: JSON-LD for artworks, Open Graph, semantic HTML, sitemap generation. Not performance or image optimization. **Version range**: Next.js 13.4+ metadata API; schema.org all versions **Generated**: 2026-04-13
$ npx -y skills add notque/vexjoy-agent --agent claude-codeHow it fires
How this agent gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
- You can call itInvoke it directly when you want it.
Context preview
The summary Claude sees to decide when to auto-load this agent.
**Scope**: JSON-LD for artworks, Open Graph, semantic HTML, sitemap generation. Not performance or image optimization. **Version range**: Next.js 13.4+ metadata API; schema.org all versions **Generated**: 2026-04-13
Agent definition
portfolio-seo.mdPortfolio SEO Reference
> **Scope**: JSON-LD for artworks, Open Graph, semantic HTML, sitemap generation. Not performance or image optimization. > **Version range**: Next.js 13.4+ metadata API; schema.org all versions > **Generated**: 2026-04-13
---
Pattern Table
| Pattern | Use When | |---------|----------| | `schema.org/VisualArtwork` JSON-LD | Artwork detail pages | | `schema.org/CollectionPage` JSON-LD | Gallery index pages | | `schema.org/Person` + `ProfilePage` | Artist about/bio page | | `og:type = "article"` | Individual artwork pages | | `og:type = "website"` | Homepage, gallery index | | `next-sitemap` | Sites with 10+ pages |
---
Correct Patterns
JSON-LD for Artwork Pages
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'VisualArtwork',
name: artwork.title,
description: artwork.description,
image: `https://yourportfolio.com${artwork.imageUrl}`,
creator: { '@type': 'Person', name: artwork.artistName, url: 'https://yourportfolio.com' },
dateCreated: artwork.year,
artMedium: artwork.medium,
width: { '@type': 'Distance', name: `${artwork.widthCm} cm` },
height: { '@type': 'Distance', name: `${artwork.heightCm} cm` },
}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />---
JSON-LD for Gallery Collection Page
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Portfolio Gallery | Artist Name',
hasPart: artworks.map(a => ({
'@type': 'VisualArtwork',
name: a.title,
url: `https://yourportfolio.com/gallery/${a.slug}`,
})),
}---
Open Graph and Twitter Cards
export async function generateMetadata({ params }): Promise<Metadata> {
const artwork = await getArtwork(params.slug)
return {
title: `${artwork.title} | Artist Portfolio`,
openGraph: {
type: 'article',
images: [{ url: artwork.imageUrl, width: artwork.width, height: artwork.height,
alt: `${artwork.title} — ${artwork.medium} by ${artwork.artistName}` }],
},
twitter: { card: 'summary_large_image', images: [artwork.imageUrl] },
}
}`summary_large_image` shows artwork at full banner width.
---
Semantic HTML
<main>
<header><h1>Portfolio Gallery</h1></header>
<div role="group" aria-label="Gallery categories"><CategoryFilter /></div>
<ul className="grid" aria-label="Artwork gallery">
{artworks.map(artwork => (
<li key={artwork.id}>
<article>
<figure>
<Image src={artwork.src} alt={artwork.alt} width={600} height={400} />
<figcaption>{artwork.title}, {artwork.year}</figcaption>
</figure>
</article>
</li>
))}
</ul>
</main>`<article>` = self-contained content. `<figure>` + `<figcaption>` = semantic image-caption pair.
---
Sitemap Generation
// next-sitemap.config.js
module.exports = {
siteUrl: 'https://yourportfolio.com',
generateRobotsTxt: true,
changefreq: 'monthly',
exclude: ['/api/*'],
additionalPaths: async () => {
const artworks = await getArtworks()
return artworks.map(a => ({
loc: `/gallery/${a.slug}`, changefreq: 'yearly', priority: 0.9, lastmod: a.updatedAt,
}))
},
}---
Pattern Catalog
Use JSON.stringify for dangerouslySetInnerHTML
// BAD — XSS risk
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: `{"name": "${artwork.title}"}` }} />
// GOOD
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />**Detection**: `rg "dangerouslySetInnerHTML.*\\\$\{" --include="*.tsx"`
---
Per-Artwork og:image (Not Generic Fallback)
**Detection**: `rg "og-default" --include="*.tsx"`
Use `generateMetadata` with artwork-specific image, not a shared fallback.
---
Title Template in Root Layout
// app/layout.tsx
export const metadata: Metadata = {
title: { default: 'Artist Portfolio', template: '%s | Artist Portfolio' }
}
// app/about/page.tsx
export const metadata: Metadata = { title: 'About' }
// Result: "About | Artist Portfolio"---
Error-Fix Mapping
| Symptom | Fix | |---------|-----| | GSC: "Missing field 'image'" | Use absolute URL in JSON-LD `image` | | No social preview | Use absolute URL for `og:image` | | Duplicate title tags | Use `title.template` in root layout | | "dateCreated must be a date" | Use string `"2024"` or ISO date | | Sitemap 404s | Add slug to `generateStaticParams` |
---
Loading Table
| Signal | Load | |--------|------| | Structured data, JSON-LD, schema.org | this file | | Open Graph, og:image, social preview | this file | | Sitemap, robots.txt, SEO | this file | | Semantic HTML, figcaption | this file | | Metadata, generateMetadata, title template | this file + nextjs-app-router.md |
Read more
Portfolio SEO Reference
> **Scope**: JSON-LD for artworks, Open Graph, semantic HTML, sitemap generation. Not performance or image optimization. > **Version range**: Next.js 13.4+ metadata API; schema.org all versions > **Generated**: 2026-04-13
---
Pattern Table
| Pattern | Use When | |---------|----------| | `schema.org/VisualArtwork` JSON-LD | Artwork detail pages | | `schema.org/CollectionPage` JSON-LD | Gallery index pages | | `schema.org/Person` + `ProfilePage` | Artist about/bio page | | `og:type = "article"` | Individual artwork pages | | `og:type = "website"` | Homepage, gallery index | | `next-sitemap` | Sites with 10+ pages |
---
Correct Patterns
JSON-LD for Artwork Pages
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'VisualArtwork',
name: artwork.title,
description: artwork.description,
image: `https://yourportfolio.com${artwork.imageUrl}`,
creator: { '@type': 'Person', name: artwork.artistName, url: 'https://yourportfolio.com' },
dateCreated: artwork.year,
artMedium: artwork.medium,
width: { '@type': 'Distance', name: `${artwork.widthCm} cm` },
height: { '@type': 'Distance', name: `${artwork.heightCm} cm` },
}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />---
JSON-LD for Gallery Collection Page
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Portfolio Gallery | Artist Name',
hasPart: artworks.map(a => ({
'@type': 'VisualArtwork',
name: a.title,
url: `https://yourportfolio.com/gallery/${a.slug}`,
})),
}---
Open Graph and Twitter Cards
export async function generateMetadata({ params }): Promise<Metadata> {
const artwork = await getArtwork(params.slug)
return {
title: `${artwork.title} | Artist Portfolio`,
openGraph: {
type: 'article',
images: [{ url: artwork.imageUrl, width: artwork.width, height: artwork.height,
alt: `${artwork.title} — ${artwork.medium} by ${artwork.artistName}` }],
},
twitter: { card: 'summary_large_image', images: [artwork.imageUrl] },
}
}`summary_large_image` shows artwork at full banner width.
---
Semantic HTML
<main>
<header><h1>Portfolio Gallery</h1></header>
<div role="group" aria-label="Gallery categories"><CategoryFilter /></div>
<ul className="grid" aria-label="Artwork gallery">
{artworks.map(artwork => (
<li key={artwork.id}>
<article>
<figure>
<Image src={artwork.src} alt={artwork.alt} width={600} height={400} />
<figcaption>{artwork.title}, {artwork.year}</figcaption>
</figure>
</article>
</li>
))}
</ul>
</main>`<article>` = self-contained content. `<figure>` + `<figcaption>` = semantic image-caption pair.
---
Sitemap Generation
// next-sitemap.config.js
module.exports = {
siteUrl: 'https://yourportfolio.com',
generateRobotsTxt: true,
changefreq: 'monthly',
exclude: ['/api/*'],
additionalPaths: async () => {
const artworks = await getArtworks()
return artworks.map(a => ({
loc: `/gallery/${a.slug}`, changefreq: 'yearly', priority: 0.9, lastmod: a.updatedAt,
}))
},
}---
Pattern Catalog
Use JSON.stringify for dangerouslySetInnerHTML
// BAD — XSS risk
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: `{"name": "${artwork.title}"}` }} />
// GOOD
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />**Detection**: `rg "dangerouslySetInnerHTML.*\\\$\{" --include="*.tsx"`
---
Per-Artwork og:image (Not Generic Fallback)
**Detection**: `rg "og-default" --include="*.tsx"`
Use `generateMetadata` with artwork-specific image, not a shared fallback.
---
Title Template in Root Layout
// app/layout.tsx
export const metadata: Metadata = {
title: { default: 'Artist Portfolio', template: '%s | Artist Portfolio' }
}
// app/about/page.tsx
export const metadata: Metadata = { title: 'About' }
// Result: "About | Artist Portfolio"---
Error-Fix Mapping
| Symptom | Fix | |---------|-----| | GSC: "Missing field 'image'" | Use absolute URL in JSON-LD `image` | | No social preview | Use absolute URL for `og:image` | | Duplicate title tags | Use `title.template` in root layout | | "dateCreated must be a date" | Use string `"2024"` or ISO date | | Sitemap 404s | Add slug to `generateStaticParams` |
---
Loading Table
| Signal | Load | |--------|------| | Structured data, JSON-LD, schema.org | this file | | Open Graph, og:image, social preview | this file | | Sitemap, robots.txt, SEO | this file | | Semantic HTML, figcaption | this file | | Metadata, generateMetadata, title template | this file + nextjs-app-router.md |
Essays and writing behind this toolkit live at vexjoy.com. AI agents skip steps. "Looks correct" replaces running tests. "Trivial change" replaces verification.
Repo: notque/vexjoy-agent
Other agents on vexjoy-agent.
- ansible-automation-engineer
Ansible automation: playbooks, roles, collections, Molecule testing, Vault security.
Open agent - modules
**Scope**: Module selection patterns, builtin vs command/shell decisions, collection modules, and version-specific module changes **Version range**: ansible-core 2.14+ / Ansible Collections (community.general 7.0+) **Generated**: 2026-04-04 — verify against current Ansible
Open agent - testing
**Scope**: Molecule test scenarios, ansible-lint rules, idempotency validation, and check-mode patterns **Version range**: Molecule 6.0+ / ansible-lint 6.0+ / ansible-core 2.14+ **Generated**: 2026-04-04 — verify against current Molecule and ansible-lint documentation
Open agent - base-instructions
Universal operational rules injected by /do at agent dispatch. Domain-specific rules live in each agent's .md file.
Open agent - communication-patterns
**Scope**: Failure modes in agent output style — over-reporting, self-congratulation, verbose narration, and hedging. Covers what to detect and how to fix each. **Version range**: all versions **Generated**: 2026-05-11
Open agent - combat-effects-upgrade
Zero-dependency combat visual upgrades: CSS particle replacement, Framer Motion combat juice, CSS 3D card transforms.
Open agent

