Gridsome

Vue.js + GraphQLベースのSSG。GatsbyのVue.js版として開発され、8k超のGitHubスターを持つ。

言語:JavaScript/TypeScript
フレームワーク:Vue.js
ビルド速度:Medium
GitHub Stars:8k
初回リリース:2018
人気ランキング:第9位

トレンド・動向

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>&copy; {{ 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 ```