Gridsome
Vue.js + GraphQLベースのSSG。GatsbyのVue.js版として開発され、8k超のGitHubスターを持つ。
トレンド・動向
Vue.jsでGatsby風の開発体験を求める開発者に支持される。GraphQLによるデータ管理が特徴。
# 静的サイトジェネレータ
Gridsome
## 概要
Gridsomeは「Vue.js + GraphQLベースのSSG」として開発された、GatsbyのVue.js版として位置づけられる静的サイトジェネレータです。8k超のGitHubスターを誇り、Vue.jsでGatsby風の開発体験を求める開発者に支持されています。2018年の開発開始以来、Vue.jsエコシステムでの統一的なデータ管理とGraphQLによる強力なデータレイヤーを特徴とし、Jamstackアプローチによる高速で安全なウェブサイト構築を実現します。静的HTMLとして配信され、その後完全なVue.js SPAとしてハイドレートされる独自のアーキテクチャにより、優れたパフォーマンスと開発効率を両立しています。
## 詳細
Gridsome 1.x版は2018年のリリースから、Vue.js開発者がGatsbyと同等の開発体験をVue.jsエコシステムで実現することを目的に設計されました。PRPLパターンの採用により、コード分割、アセット最適化、プログレッシブ画像、リンクプリフェッチが標準で組み込まれています。GraphQLデータレイヤーにより、ヘッドレスCMS、API、Markdownファイルなど多様なデータソースを統一的に管理可能。ページごとに1つの.htmlファイルと1つの.jsonファイルを生成し、初回ページロード後は.jsonファイルのみを使用した高速なページ遷移を実現します。57KB min gzip(Vue.js、vue-router、vue-meta、画像遅延読み込み含む)の軽量バンドルサイズで、ほぼ完璧なページスピードスコアを達成します。
### 主な特徴
- **Vue.jsベースのフロントエンド**: シンプルで親しみやすいVue.jsによる開発体験
- **GraphQLデータレイヤー**: 統一されたクエリインターフェースによるデータ管理
- **自動コード分割**: ページごとの最適化されたJSバンドル生成
- **ファイルベースルーティング**: src/pages内の.vueファイルによる自動ルート生成
- **PWA対応**: オフラインファーストアーキテクチャとプログレッシブウェブアプリ機能
- **プリフェッチ最適化**: 次ページのデータを自動で先読み
## メリット・デメリット
### メリット
- Vue.jsエコシステムでの親しみやすい開発体験とコンポーネントベース設計
- GraphQLによる統一的で強力なデータ管理・統合機能
- 自動パフォーマンス最適化による軽量かつ高速なサイト生成
- ヘッドレスCMS、API、Markdownの柔軟な データソース統合
- GatsbyライクなJamstack開発体験をVue.jsで実現
- CDN配信とサーバーレス環境での低コスト運用対応
### デメリット
- Gatsbyと比較した場合のプラグインエコシステムの規模不足
- Vue.jsコミュニティでもNuxt.jsほどの成熟度と採用実績不足
- GraphQL学習コストと小規模サイトでの設定複雑性
- 2024年時点での開発活動低下と将来性への懸念
- React/Gatsbyほどの豊富な学習リソースと企業採用事例不足
- 大規模サイトでのビルド時間とメモリ使用量の課題
## 参考ページ
- [Gridsome 公式サイト](https://gridsome.org/)
- [Gridsome ドキュメント](https://gridsome.org/docs/)
- [Gridsome GitHub リポジトリ](https://github.com/gridsome/gridsome)
## 書き方の例
### インストールとプロジェクト作成
```bash
# Gridsome CLIのインストール
npm install --global @gridsome/cli
# プロジェクト作成
gridsome create my-gridsome-site
# プロジェクトディレクトリに移動
cd my-gridsome-site
# 開発サーバー起動
gridsome develop
# 本番ビルド
gridsome build
# ビルド結果の確認
gridsome serve
# テンプレートからプロジェクト作成
gridsome create my-blog https://github.com/gridsome/gridsome-starter-blog.git
# CLIバージョン確認
gridsome --version
```
### 基本設定(gridsome.config.js)
```javascript
// gridsome.config.js
module.exports = {
siteName: 'Gridsome',
siteDescription: 'A Vue.js & GraphQL powered static site generator',
siteUrl: 'https://www.example.com',
plugins: [
// Markdownファイルをデータソースとして追加
{
use: '@gridsome/source-filesystem',
options: {
typeName: 'Post',
path: 'content/posts/**/*.md',
refs: {
tags: {
typeName: 'Tag',
create: true
},
author: {
typeName: 'Author',
create: true
}
}
}
},
// WordPress連携
{
use: '@gridsome/source-wordpress',
options: {
baseUrl: 'https://your-wordpress-site.com',
typeName: 'WordPress',
perPage: 100,
concurrent: 10
}
},
// Contentful CMS連携
{
use: '@gridsome/source-contentful',
options: {
space: 'your-space-id',
accessToken: 'your-access-token',
host: 'cdn.contentful.com',
environment: 'master',
typeName: 'Contentful'
}
},
// Strapi CMS連携
{
use: '@gridsome/source-strapi',
options: {
apiURL: 'http://localhost:1337',
queryLimit: 1000,
contentTypes: ['post', 'tag', 'author'],
typeName: 'Strapi'
}
},
// Google Analytics
{
use: '@gridsome/plugin-google-analytics',
options: {
id: 'UA-XXXXXXXXX-X'
}
},
// サイトマップ生成
{
use: '@gridsome/plugin-sitemap',
options: {
cacheTime: 600000,
exclude: ['/exclude-me'],
config: {
'/*': {
changefreq: 'weekly',
priority: 0.5
},
'/about': {
changefreq: 'monthly',
priority: 0.7
}
}
}
},
// PWA機能
{
use: 'gridsome-plugin-pwa',
options: {
title: 'Gridsome',
startUrl: '/',
display: 'standalone',
statusBarStyle: 'default',
manifestPath: 'manifest.json',
disableServiceWorker: false,
serviceWorkerPath: 'service-worker.js',
cachedFileTypes: 'js,json,css,html,png,jpg,jpeg,svg',
shortName: 'Gridsome',
themeColor: '#666600',
backgroundColor: '#ffffff',
icon: 'src/favicon.png',
msTileImage: '',
msTileColor: '#666600'
}
}
],
// Sass/SCSS使用
css: {
loaderOptions: {
scss: {
additionalData: `@import "~/assets/scss/globals.scss";`
}
}
},
// カスタムwebpack設定
configureWebpack: {
resolve: {
alias: {
'@': __dirname + '/src'
}
}
},
// ビルド設定
outputDir: 'dist',
pathPrefix: '',
// PWA設定
icon: {
favicon: './src/favicon.png',
touchicon: './src/favicon.png'
},
// 変換設定
transformers: {
remark: {
externalLinksTarget: '_blank',
externalLinksRel: ['nofollow', 'noopener', 'noreferrer'],
anchorClassName: 'icon icon-link',
plugins: [
'@gridsome/remark-prismjs'
]
}
}
}
```
### ページとコンポーネント作成
```vue
<!-- src/pages/Index.vue -->
<template>
<Layout>
<h1>Welcome to Gridsome!</h1>
<p>This is our homepage built with Vue.js and GraphQL.</p>
<!-- 最新記事の表示 -->
<div class="posts">
<h2>Latest Posts</h2>
<div class="post-grid">
<article v-for="edge in $page.posts.edges" :key="edge.node.id" class="post-card">
<g-link :to="edge.node.path" class="post-link">
<g-image
v-if="edge.node.featuredImage"
:src="edge.node.featuredImage"
:alt="edge.node.title"
class="post-image"
/>
<div class="post-content">
<h3>{{ edge.node.title }}</h3>
<p>{{ edge.node.excerpt }}</p>
<div class="post-meta">
<time :datetime="edge.node.date">{{ formatDate(edge.node.date) }}</time>
<span v-if="edge.node.author"> by {{ edge.node.author }}</span>
</div>
<div class="post-tags">
<span v-for="tag in edge.node.tags" :key="tag.id" class="tag">
{{ tag.title }}
</span>
</div>
</div>
</g-link>
</article>
</div>
</div>
<!-- 特集セクション -->
<section class="featured">
<h2>Featured Content</h2>
<div class="featured-grid">
<div class="featured-item">
<h3>Vue.js Guide</h3>
<p>Complete guide to building modern web applications with Vue.js</p>
<g-link to="/guides/vue/" class="btn">Learn More</g-link>
</div>
<div class="featured-item">
<h3>GraphQL Tutorial</h3>
<p>Master GraphQL data querying with practical examples</p>
<g-link to="/tutorials/graphql/" class="btn">Get Started</g-link>
</div>
</div>
</section>
</Layout>
</template>
<page-query>
query {
posts: allPost(limit: 6, sortBy: "date", order: DESC) {
edges {
node {
id
title
excerpt
date (format: "YYYY-MM-DD")
path
author
featuredImage
tags {
id
title
}
}
}
}
}
</page-query>
<script>
export default {
metaInfo: {
title: 'Home',
titleTemplate: '%s | My Gridsome Site'
},
methods: {
formatDate(date) {
return new Date(date).toLocaleDateString('ja-JP', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
}
}
</script>
<style scoped>
.posts {
margin: 3rem 0;
}
.post-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.post-card {
border: 1px solid #e1e5e9;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.post-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.post-link {
text-decoration: none;
color: inherit;
display: block;
}
.post-image {
width: 100%;
height: 200px;
object-fit: cover;
}
.post-content {
padding: 1.5rem;
}
.post-meta {
font-size: 0.9rem;
color: #666;
margin: 1rem 0;
}
.post-tags {
margin-top: 1rem;
}
.tag {
display: inline-block;
background: #f1f3f4;
color: #333;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
margin-right: 0.5rem;
}
.featured {
margin: 4rem 0;
padding: 3rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
}
.featured-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.featured-item {
text-align: center;
padding: 2rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.btn {
display: inline-block;
background: #fff;
color: #333;
padding: 0.75rem 1.5rem;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
margin-top: 1rem;
transition: transform 0.2s;
}
.btn:hover {
transform: translateY(-2px);
}
</style>
```
### GraphQLデータ取得とページ生成
```vue
<!-- src/templates/Post.vue -->
<template>
<Layout>
<article class="post">
<header class="post-header">
<g-image
v-if="$page.post.featuredImage"
:src="$page.post.featuredImage"
:alt="$page.post.title"
class="featured-image"
/>
<div class="post-meta-header">
<h1>{{ $page.post.title }}</h1>
<div class="post-meta">
<time :datetime="$page.post.date">{{ formatDate($page.post.date) }}</time>
<span v-if="$page.post.author" class="author">by {{ $page.post.author }}</span>
<span class="reading-time">{{ readingTime }} min read</span>
</div>
<div class="post-tags">
<g-link
v-for="tag in $page.post.tags"
:key="tag.id"
:to="tag.path"
class="tag"
>
{{ tag.title }}
</g-link>
</div>
</div>
</header>
<div class="post-content" v-html="$page.post.content"></div>
<footer class="post-footer">
<div class="post-navigation">
<g-link
v-if="$page.previous"
:to="$page.previous.path"
class="nav-link prev"
>
← {{ $page.previous.title }}
</g-link>
<g-link
v-if="$page.next"
:to="$page.next.path"
class="nav-link next"
>
{{ $page.next.title }} →
</g-link>
</div>
<!-- 関連記事 -->
<section v-if="$page.related.edges.length" class="related-posts">
<h3>Related Posts</h3>
<div class="related-grid">
<article v-for="edge in $page.related.edges" :key="edge.node.id" class="related-post">
<g-link :to="edge.node.path">
<h4>{{ edge.node.title }}</h4>
<p>{{ edge.node.excerpt }}</p>
</g-link>
</article>
</div>
</section>
</footer>
</article>
</Layout>
</template>
<page-query>
query ($id: ID!, $previousElement: ID, $nextElement: ID) {
post: post (id: $id) {
title
content
excerpt
date (format: "YYYY-MM-DD")
author
featuredImage
tags {
id
title
path
}
}
previous: post (id: $previousElement) {
title
path
}
next: post (id: $nextElement) {
title
path
}
related: allPost (filter: { id: { ne: $id } }, limit: 3) {
edges {
node {
id
title
excerpt
path
}
}
}
}
</page-query>
<script>
export default {
computed: {
readingTime() {
const content = this.$page.post.content
const wordsPerMinute = 200
const textLength = content.replace(/<[^>]*>/g, '').length
const words = textLength / 5 // 平均的な単語長
return Math.ceil(words / wordsPerMinute)
}
},
metaInfo() {
return {
title: this.$page.post.title,
meta: [
{ name: 'description', content: this.$page.post.excerpt },
{ property: 'og:title', content: this.$page.post.title },
{ property: 'og:description', content: this.$page.post.excerpt },
{ property: 'og:type', content: 'article' },
{ property: 'article:published_time', content: this.$page.post.date },
{ property: 'article:author', content: this.$page.post.author }
]
}
},
methods: {
formatDate(date) {
return new Date(date).toLocaleDateString('ja-JP', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
}
}
</script>
<style scoped>
.post {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.post-header {
margin-bottom: 3rem;
}
.featured-image {
width: 100%;
height: 400px;
object-fit: cover;
border-radius: 8px;
margin-bottom: 2rem;
}
.post-meta-header h1 {
font-size: 2.5rem;
line-height: 1.2;
margin-bottom: 1rem;
}
.post-meta {
color: #666;
margin-bottom: 1rem;
}
.post-meta > * {
margin-right: 1rem;
}
.post-tags {
margin-bottom: 2rem;
}
.tag {
display: inline-block;
background: #e3f2fd;
color: #1976d2;
padding: 0.25rem 0.75rem;
border-radius: 20px;
text-decoration: none;
font-size: 0.9rem;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
transition: background-color 0.2s;
}
.tag:hover {
background: #bbdefb;
}
.post-content {
line-height: 1.8;
font-size: 1.1rem;
}
.post-content >>> h2 {
margin-top: 3rem;
margin-bottom: 1rem;
color: #333;
}
.post-content >>> h3 {
margin-top: 2rem;
margin-bottom: 1rem;
color: #555;
}
.post-content >>> p {
margin-bottom: 1.5rem;
}
.post-content >>> pre {
background: #f5f5f5;
padding: 1.5rem;
border-radius: 8px;
overflow-x: auto;
margin: 2rem 0;
}
.post-content >>> blockquote {
border-left: 4px solid #ddd;
padding-left: 1rem;
margin: 2rem 0;
font-style: italic;
color: #666;
}
.post-footer {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid #eee;
}
.post-navigation {
display: flex;
justify-content: space-between;
margin-bottom: 3rem;
}
.nav-link {
text-decoration: none;
color: #1976d2;
font-weight: 600;
padding: 0.75rem 1.5rem;
border: 2px solid #1976d2;
border-radius: 6px;
transition: all 0.2s;
}
.nav-link:hover {
background: #1976d2;
color: white;
}
.related-posts {
margin-top: 3rem;
}
.related-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.related-post {
padding: 1.5rem;
border: 1px solid #eee;
border-radius: 8px;
transition: transform 0.2s;
}
.related-post:hover {
transform: translateY(-2px);
}
.related-post a {
text-decoration: none;
color: inherit;
}
.related-post h4 {
margin-bottom: 0.5rem;
color: #333;
}
</style>
```
### レイアウトとナビゲーション
```vue
<!-- src/layouts/Default.vue -->
<template>
<div class="layout">
<header class="header">
<div class="container">
<nav class="nav">
<g-link to="/" class="nav-logo">
<g-image src="~/assets/images/logo.png" alt="Logo" />
<span>My Gridsome Site</span>
</g-link>
<ul class="nav-menu">
<li><g-link to="/" class="nav-link">Home</g-link></li>
<li><g-link to="/blog/" class="nav-link">Blog</g-link></li>
<li><g-link to="/about/" class="nav-link">About</g-link></li>
<li><g-link to="/contact/" class="nav-link">Contact</g-link></li>
</ul>
<!-- モバイルメニュートグル -->
<button
class="nav-toggle"
@click="toggleMobileMenu"
:class="{ active: mobileMenuOpen }"
>
<span></span>
<span></span>
<span></span>
</button>
</nav>
<!-- モバイルメニュー -->
<div class="mobile-menu" :class="{ open: mobileMenuOpen }">
<ul class="mobile-nav">
<li><g-link to="/" @click="closeMobileMenu">Home</g-link></li>
<li><g-link to="/blog/" @click="closeMobileMenu">Blog</g-link></li>
<li><g-link to="/about/" @click="closeMobileMenu">About</g-link></li>
<li><g-link to="/contact/" @click="closeMobileMenu">Contact</g-link></li>
</ul>
</div>
</div>
</header>
<main class="main">
<slot/>
</main>
<footer class="footer">
<div class="container">
<div class="footer-content">
<div class="footer-section">
<h3>About</h3>
<p>Building amazing websites with Vue.js and GraphQL using Gridsome.</p>
</div>
<div class="footer-section">
<h3>Links</h3>
<ul>
<li><g-link to="/blog/">Blog</g-link></li>
<li><g-link to="/about/">About</g-link></li>
<li><g-link to="/contact/">Contact</g-link></li>
<li><g-link to="/privacy/">Privacy Policy</g-link></li>
</ul>
</div>
<div class="footer-section">
<h3>Social</h3>
<div class="social-links">
<a href="https://twitter.com/yourusername" target="_blank" rel="noopener">Twitter</a>
<a href="https://github.com/yourusername" target="_blank" rel="noopener">GitHub</a>
<a href="https://linkedin.com/in/yourusername" target="_blank" rel="noopener">LinkedIn</a>
</div>
</div>
</div>
<div class="footer-bottom">
<p>© {{ currentYear }} My Gridsome Site. All rights reserved.</p>
</div>
</div>
</footer>
</div>
</template>
<script>
export default {
data() {
return {
mobileMenuOpen: false
}
},
computed: {
currentYear() {
return new Date().getFullYear()
}
},
methods: {
toggleMobileMenu() {
this.mobileMenuOpen = !this.mobileMenuOpen
},
closeMobileMenu() {
this.mobileMenuOpen = false
}
}
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: sticky;
top: 0;
z-index: 100;
}
.nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
}
.nav-logo {
display: flex;
align-items: center;
text-decoration: none;
font-size: 1.25rem;
font-weight: bold;
color: #333;
}
.nav-logo img {
width: 40px;
height: 40px;
margin-right: 0.5rem;
}
.nav-menu {
display: flex;
list-style: none;
gap: 2rem;
}
.nav-link {
text-decoration: none;
color: #333;
font-weight: 500;
transition: color 0.2s;
}
.nav-link:hover,
.nav-link.active--exact {
color: #667eea;
}
.nav-toggle {
display: none;
flex-direction: column;
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
}
.nav-toggle span {
width: 25px;
height: 3px;
background: #333;
margin: 3px 0;
transition: 0.3s;
}
.mobile-menu {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.mobile-nav {
list-style: none;
padding: 1rem;
}
.mobile-nav li {
margin-bottom: 1rem;
}
.mobile-nav a {
text-decoration: none;
color: #333;
font-size: 1.1rem;
}
.main {
flex: 1;
padding: 2rem 0;
}
.footer {
background: #f8f9fa;
margin-top: 4rem;
}
.footer-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
padding: 3rem 0;
}
.footer-section h3 {
margin-bottom: 1rem;
color: #333;
}
.footer-section ul {
list-style: none;
}
.footer-section ul li {
margin-bottom: 0.5rem;
}
.footer-section a {
color: #666;
text-decoration: none;
transition: color 0.2s;
}
.footer-section a:hover {
color: #667eea;
}
.social-links {
display: flex;
gap: 1rem;
}
.footer-bottom {
border-top: 1px solid #dee2e6;
padding: 1.5rem 0;
text-align: center;
color: #666;
}
/* レスポンシブ対応 */
@media (max-width: 768px) {
.nav-menu {
display: none;
}
.nav-toggle {
display: flex;
}
.mobile-menu.open {
display: block;
}
.nav-toggle.active span:nth-child(1) {
transform: rotate(-45deg) translate(-5px, 6px);
}
.nav-toggle.active span:nth-child(2) {
opacity: 0;
}
.nav-toggle.active span:nth-child(3) {
transform: rotate(45deg) translate(-5px, -6px);
}
}
</style>
```
### データソース連携と動的ページ生成
```javascript
// gridsome.server.js
const axios = require('axios')
module.exports = function (api) {
// サーバーAPI初期化時のデータ読み込み
api.loadSource(async actions => {
// 外部APIからデータを取得
const { data } = await axios.get('https://jsonplaceholder.typicode.com/posts')
// GraphQLコレクションを作成
const collection = actions.addCollection({
typeName: 'ExternalPost'
})
// データをコレクションに追加
for (const post of data) {
collection.addNode({
id: post.id,
title: post.title,
content: post.body,
userId: post.userId
})
}
// カスタムスキーマ定義
actions.addSchemaTypes(`
type Post implements Node @infer {
id: ID!
title: String!
content: String!
excerpt: String
featuredImage: Image
tags: [Tag] @reference(by: "title", to: "title")
author: Author @reference(by: "title", to: "title")
}
type Tag implements Node @infer {
id: ID!
title: String!
posts: [Post] @reference(by: "title", to: "tags")
}
type Author implements Node @infer {
id: ID!
title: String!
bio: String
avatar: Image
posts: [Post] @reference(by: "title", to: "author")
}
`)
})
// 動的ページの作成
api.createPages(({ createPage }) => {
// 年別アーカイブページ作成
createPage({
path: '/archive/:year',
component: './src/templates/Archive.vue'
})
// タグページ作成
createPage({
path: '/tag/:id',
component: './src/templates/Tag.vue'
})
// 作者ページ作成
createPage({
path: '/author/:id',
component: './src/templates/Author.vue'
})
// カスタムページ作成
createPage({
path: '/custom/:slug',
component: './src/templates/Custom.vue'
})
})
// ビルド前フック
api.beforeBuild(({ config, store }) => {
console.log('ビルド開始...')
// 環境変数に応じた設定
if (process.env.NODE_ENV === 'production') {
// 本番環境での追加設定
config.runtimeCompiler = false
config.outputDir = 'dist'
}
})
// ビルド後フック
api.afterBuild(({ config }) => {
console.log('ビルド完了!')
// 追加の後処理
// - サイトマップ生成
// - RSS フィード作成
// - 外部サービスへの通知
})
}
```
### SEO最適化とパフォーマンス
```vue
<!-- src/components/SEO.vue -->
<template>
<div>
<!-- このコンポーネントは表示されない -->
</div>
</template>
<script>
export default {
props: {
title: String,
description: String,
image: String,
article: Boolean,
author: String,
publishedTime: String,
modifiedTime: String
},
metaInfo() {
const meta = [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: this.description },
{ name: 'robots', content: 'index,follow' },
// Open Graph
{ property: 'og:type', content: this.article ? 'article' : 'website' },
{ property: 'og:title', content: this.title },
{ property: 'og:description', content: this.description },
{ property: 'og:url', content: process.env.GRIDSOME_BASE_URL + this.$route.path },
{ property: 'og:site_name', content: 'My Gridsome Site' },
// Twitter Card
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: this.title },
{ name: 'twitter:description', content: this.description },
{ name: 'twitter:site', content: '@yourusername' },
{ name: 'twitter:creator', content: '@yourusername' }
]
// 画像メタタグ
if (this.image) {
meta.push(
{ property: 'og:image', content: this.image },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ name: 'twitter:image', content: this.image }
)
}
// 記事特有のメタタグ
if (this.article) {
if (this.author) {
meta.push({ property: 'article:author', content: this.author })
}
if (this.publishedTime) {
meta.push({ property: 'article:published_time', content: this.publishedTime })
}
if (this.modifiedTime) {
meta.push({ property: 'article:modified_time', content: this.modifiedTime })
}
}
return {
title: this.title,
meta,
link: [
{ rel: 'canonical', href: process.env.GRIDSOME_BASE_URL + this.$route.path }
]
}
}
}
</script>
<!-- src/components/ImageOptimizer.vue -->
<template>
<div class="image-wrapper">
<g-image
:src="src"
:alt="alt"
:width="width"
:height="height"
:quality="quality"
:fit="fit"
:position="position"
:blur="blur"
loading="lazy"
class="optimized-image"
@load="onImageLoad"
@error="onImageError"
/>
<div v-if="showCaption && caption" class="image-caption">
{{ caption }}
</div>
</div>
</template>
<script>
export default {
props: {
src: {
type: String,
required: true
},
alt: {
type: String,
required: true
},
width: {
type: Number,
default: 800
},
height: {
type: Number,
default: null
},
quality: {
type: Number,
default: 80
},
fit: {
type: String,
default: 'cover'
},
position: {
type: String,
default: 'center'
},
blur: {
type: Number,
default: 0
},
caption: {
type: String,
default: ''
},
showCaption: {
type: Boolean,
default: false
}
},
methods: {
onImageLoad() {
this.$emit('load')
},
onImageError() {
this.$emit('error')
console.error('画像の読み込みに失敗しました:', this.src)
}
}
}
</script>
<style scoped>
.image-wrapper {
position: relative;
display: inline-block;
}
.optimized-image {
width: 100%;
height: auto;
border-radius: 8px;
transition: transform 0.3s ease;
}
.optimized-image:hover {
transform: scale(1.02);
}
.image-caption {
margin-top: 0.5rem;
font-size: 0.9rem;
color: #666;
text-align: center;
font-style: italic;
}
</style>
```
### ビルドとデプロイメント
```json
{
"name": "my-gridsome-site",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "gridsome build",
"develop": "gridsome develop",
"explore": "gridsome explore",
"serve": "gridsome serve",
"clean": "gridsome clean",
"deploy": "npm run build && npm run deploy:netlify",
"deploy:netlify": "netlify deploy --prod --dir=dist",
"deploy:vercel": "vercel --prod",
"lint": "eslint --ext .js,.vue src/",
"lint:fix": "eslint --ext .js,.vue src/ --fix"
},
"dependencies": {
"@gridsome/plugin-google-analytics": "^0.1.2",
"@gridsome/plugin-sitemap": "^0.4.0",
"@gridsome/source-contentful": "^0.2.4",
"@gridsome/source-filesystem": "^0.6.2",
"@gridsome/source-strapi": "^0.2.0",
"@gridsome/source-wordpress": "^0.3.4",
"@gridsome/transformer-remark": "^0.6.4",
"@gridsome/vue-remark": "^0.2.6",
"gridsome": "^0.7.23",
"gridsome-plugin-pwa": "^0.0.19",
"vue": "^2.6.14",
"vue-meta": "^2.4.0"
},
"devDependencies": {
"@gridsome/cli": "^0.3.4",
"eslint": "^8.0.0",
"eslint-plugin-vue": "^8.0.0",
"sass": "^1.49.0",
"sass-loader": "^12.0.0"
}
}
```
```yaml
# .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
GRIDSOME_BASE_URL: https://your-site.com
- name: Deploy to Netlify
if: github.ref == 'refs/heads/main'
uses: nwtgck/[email protected]
with:
publish-dir: './dist'
production-branch: main
github-token: ${{ secrets.GITHUB_TOKEN }}
deploy-message: "Deploy from GitHub Actions"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
```
```javascript
// netlify.toml
[build]
command = "npm run build"
publish = "dist/"
[build.environment]
NODE_VERSION = "18"
GRIDSOME_BASE_URL = "https://your-site.netlify.app"
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-XSS-Protection = "1; mode=block"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
[[headers]]
for = "/static/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
[[redirects]]
from = "/old-page"
to = "/new-page"
status = 301
# API プロキシ設定
[[redirects]]
from = "/api/*"
to = "https://your-api.com/api/:splat"
status = 200
```