Spaces:
Sleeping
Sleeping
| import { readFileSync } from 'node:fs'; | |
| import { join } from 'node:path'; | |
| // 大区分の型定義 | |
| import type { ContentSection, SectionType } from '@/schema/proposal'; | |
| type RequestData = { | |
| companyInfo: { | |
| name: string; | |
| title?: string; | |
| }; | |
| heroSection: { | |
| コピー: { | |
| メインコピー: string; | |
| サブコピー1?: string; | |
| サブコピー2?: string; | |
| サブコピー3?: string; | |
| }; | |
| CTA: { | |
| ボタンテキスト: string; | |
| マイクロコピー?: string; | |
| }; | |
| ビジュアル?: { | |
| 内容: string; | |
| 作成指示?: string; | |
| }; | |
| 権威付け?: { | |
| 内容: string; | |
| }; | |
| }; | |
| contentSections?: ContentSection[]; | |
| }; | |
| // aria-label マッピング | |
| const ariaLabelMapping: Record<SectionType, string> = { | |
| '問題提起/共感': '問題提起/共感', | |
| シミュレーション: 'シミュレーション', | |
| '商品/サービスの特徴': '商品/サービスの特徴', | |
| '解決策/ベネフィット提示': '解決策/ベネフィット提示', | |
| 'SPオファー/限定特典': 'SPオファー/限定特典', | |
| '料金/プラン': '料金/プラン', | |
| '商品/サービスの詳細情報': '商品/サービスの詳細情報', | |
| '成功事例/ユーザーボイス紹介': '成功事例/ユーザーボイス紹介', | |
| 'ご利用の流れ/ステップ': 'ご利用の流れ/ステップ', | |
| メディア掲載情報: 'メディア掲載情報', | |
| 競合比較: '競合比較', | |
| 'スタッフ・メンバー紹介': 'スタッフ・メンバー紹介', | |
| 権威訴求: '権威訴求', | |
| 店舗情報: '店舗情報', | |
| 活用シーン: '活用シーン', | |
| 'FAQ/よくある質問': 'FAQ/よくある質問', | |
| 'News/更新情報': 'News/更新情報', | |
| }; | |
| // セクションタイプマッピング | |
| const sectionMapping: Record<SectionType, string> = { | |
| '問題提起/共感': 'problem-solution', | |
| シミュレーション: 'simulation', | |
| '商品/サービスの特徴': 'features', | |
| '解決策/ベネフィット提示': 'benefits', | |
| 'SPオファー/限定特典': 'special-offer', | |
| '料金/プラン': 'pricing', | |
| '商品/サービスの詳細情報': 'product-details', | |
| '成功事例/ユーザーボイス紹介': 'testimonials', | |
| 'ご利用の流れ/ステップ': 'usage-flow', | |
| メディア掲載情報: 'media-coverage', | |
| 競合比較: 'comparison', | |
| 'スタッフ・メンバー紹介': 'staff', | |
| 権威訴求: 'authority', | |
| 店舗情報: 'store-info', | |
| 活用シーン: 'use-cases', | |
| 'FAQ/よくある質問': 'faq', | |
| 'News/更新情報': 'news-updates', | |
| }; | |
| // HTMLテンプレート生成クラス | |
| export class ContentsHtmlGenerator { | |
| private baseTemplate: string; | |
| private imageIdCounter: number; | |
| constructor() { | |
| this.baseTemplate = this.loadTemplateFromFile(); | |
| this.imageIdCounter = 0; | |
| } | |
| /** | |
| * ユニークな画像IDを生成 | |
| */ | |
| private generateUniqueImageId(sectionType: string, index: number): string { | |
| this.imageIdCounter++; | |
| const uniqueId = `${sectionType}-img-${index}-${this.imageIdCounter}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; | |
| return uniqueId; | |
| } | |
| // メインのHTML生成メソッド | |
| generateHTML(data: RequestData): string { | |
| let html = this.baseTemplate; | |
| // 会社情報の置換 | |
| html = html.replace(/{{COMPANY_NAME}}/g, data.companyInfo.name); | |
| html = html.replace(/{{COMPANY_TITLE}}/g, data.companyInfo.title || data.heroSection.コピー.メインコピー); | |
| // ヒーローセクションの置換 | |
| html = html.replace(/{{HERO_MAIN_COPY}}/g, this.formatHeroTitle(data.heroSection.コピー.メインコピー)); | |
| html = html.replace(/{{HERO_FEATURES}}/g, this.generateHeroFeatures(data.heroSection.コピー)); | |
| html = html.replace(/{{CTA_BUTTON_TEXT}}/g, data.heroSection.CTA.ボタンテキスト); | |
| html = html.replace(/{{CTA_MICRO_COPY}}/g, data.heroSection.CTA.マイクロコピー || ''); | |
| // ヒーロー画像の置換は削除(CNデータには通常ヒーロー画像はなく、コンテンツ画像のIDが上書きされる問題を防ぐため) | |
| // コンテンツセクションの生成 | |
| const contentSections = this.generateContentSections(data.contentSections || []); | |
| html = html.replace(/{{CONTENT_SECTIONS}}/g, contentSections); | |
| // 権威付けセクションの生成 | |
| const authoritySection = this.generateAuthoritySection(data.heroSection.権威付け); | |
| html = html.replace(/{{AUTHORITY_SECTION}}/g, authoritySection); | |
| return html; | |
| } | |
| /** | |
| * ヒーロータイトルのフォーマット | |
| */ | |
| private formatHeroTitle(title: string): string { | |
| return title || ''; | |
| } | |
| /** | |
| * ヒーローフィーチャーの生成 | |
| */ | |
| private generateHeroFeatures(copyData: { メインコピー: string; サブコピー1?: string; サブコピー2?: string; サブコピー3?: string }): string { | |
| const features = []; | |
| if (copyData.サブコピー1) features.push(copyData.サブコピー1); | |
| if (copyData.サブコピー2) features.push(copyData.サブコピー2); | |
| if (copyData.サブコピー3) features.push(copyData.サブコピー3); | |
| if (features.length === 0) { | |
| return ''; | |
| } | |
| return features.map((feature) => `<li>${feature}</li>`).join(''); | |
| } | |
| /** | |
| * コンテンツセクションとフッターのみを生成(ヘッダーとヒーローセクションを除外) | |
| */ | |
| generateContentSectionsOnly(data: { contentSections?: any[]; companyName?: string }): string { | |
| // コンテンツセクションの生成 | |
| const contentSections = this.generateContentSections(data.contentSections || []); | |
| // フッターを追加(コンテンツ単体で表示される場合のため) | |
| const footer = this.generateFooterSection(data.companyName); | |
| const html = contentSections + footer; | |
| return html; | |
| } | |
| // コンテンツセクションの生成 | |
| private generateContentSections(sections: ContentSection[]): string { | |
| const results = sections.map((section) => { | |
| const sectionType = this.mapSectionType(section.大区分); | |
| const html = this.generateSectionByType(sectionType, section); | |
| return html; | |
| }); | |
| // すべてのセクションを返す(空のチェックを削除) | |
| return results.join(''); | |
| } | |
| // 大区分からセクションタイプへのマッピング | |
| private mapSectionType(type: SectionType): string { | |
| return sectionMapping[type] || 'generic'; | |
| } | |
| // セクションタイプ別のHTML生成 | |
| private generateSectionByType(type: string, section: ContentSection): string { | |
| switch (type) { | |
| case 'features': | |
| return this.generateFeaturesSection(section); | |
| case 'benefits': | |
| return this.generateBenefitsSection(section); | |
| case 'authority': | |
| return this.generateAuthorityContentSection(section); | |
| case 'problem-solution': | |
| return this.generateProblemSolutionSection(section); | |
| case 'usage-flow': | |
| return this.generateUsageFlowSection(section); | |
| case 'product-details': | |
| return this.generateProductDetailsSection(section); | |
| case 'simulation': | |
| return this.generateSimulationSection(section); | |
| case 'special-offer': | |
| return this.generateSpecialOfferSection(section); | |
| case 'pricing': | |
| return this.generatePricingSection(section); | |
| case 'testimonials': | |
| return this.generateTestimonialsSection(section); | |
| case 'media-coverage': | |
| return this.generateMediaCoverageSection(section); | |
| case 'comparison': | |
| return this.generateComparisonSection(section); | |
| case 'staff': | |
| return this.generateStaffSection(section); | |
| case 'store-info': | |
| return this.generateStoreInfoSection(section); | |
| case 'use-cases': | |
| return this.generateUseCasesSection(section); | |
| case 'faq': | |
| return this.generateFaqSection(section); | |
| case 'news-updates': | |
| return this.generateNewsUpdatesSection(section); | |
| default: | |
| return this.generateGenericSection(section); | |
| } | |
| } | |
| // 特徴セクションの生成 | |
| private generateFeaturesSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map((item, index) => { | |
| // ユニークIDを生成(タイムスタンプ + インデックス + ランダム文字列) | |
| const imageId = this.generateUniqueImageId('feature', index); | |
| // 変数を即座にテンプレートに埋め込む(クロージャの問題を回避) | |
| const imageAlt = section.大区分 || '画像生成中...'; | |
| const imageTitle = section.大区分 || ''; | |
| const itemNumber = String(index + 1).padStart(2, '0'); | |
| const itemHeading = item.見出し?.value || ''; | |
| const itemContent = item.内容?.value || ''; | |
| // 4:3のプレースホルダーを生成 | |
| const placeholderHtml = `<img | |
| id="${imageId}" | |
| class="innovation__image" | |
| src="/loading-placeholder.svg" | |
| alt="${imageAlt}" | |
| title="${imageTitle}" | |
| width="800" | |
| height="600" | |
| data-image-status="loading" | |
| data-image-role="illustration" | |
| data-section-type="features" | |
| style="background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; object-fit: cover; aspect-ratio: 4 / 3;" | |
| loading="lazy" />`; | |
| return ` | |
| <div class="innovation__item"> | |
| <div class="innovation__number">${itemNumber}</div> | |
| <div class="innovation__content"> | |
| ${placeholderHtml} | |
| <h3 class="innovation__subtitle">${itemHeading}</h3> | |
| <p class="innovation__text">${itemContent}</p> | |
| </div> | |
| </div> | |
| `; | |
| }) | |
| .join('') || ''; | |
| return ` | |
| <section class="innovation section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="innovation__container"> | |
| <h2 class="innovation__title">${section.中区分}</h2> | |
| ${items} | |
| </div> | |
| </section> | |
| <!-- /.innovation --> | |
| `; | |
| } | |
| // ベネフィットセクションの生成 | |
| private generateBenefitsSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map( | |
| (item, index) => ` | |
| <div class="solution-benefits-section__item"> | |
| <div class="solution-benefits-section__header"> | |
| <div class="solution-benefits-section__icon">✓</div> | |
| ${item.見出し?.value ? `<h3 class="solution-benefits-section__item-title">${item.見出し.value}</h3>` : ''} | |
| </div> | |
| <div class="solution-benefits-section__text"> | |
| ${item.内容?.value || ''} | |
| ${item.注釈?.value ? `<span class="solution-benefits-section__note">${item.注釈.value}</span>` : ''} | |
| </div> | |
| </div> | |
| `, | |
| ) | |
| .join('') || ''; | |
| return ` | |
| <section class="solution-benefits-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="solution-benefits-section__container"> | |
| <h2 class="solution-benefits-section__title">${section.中区分}</h2> | |
| <div class="solution-benefits-section__items"> | |
| ${items} | |
| </div> | |
| </div> | |
| </section> | |
| <!-- /.solution-benefits-section --> | |
| `; | |
| } | |
| // 権威付けセクションの生成 | |
| private generateAuthorityContentSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map( | |
| (item, index) => ` | |
| <div class="award-item"> | |
| <div class="award-wreath"> | |
| <div class="award-title">${item.見出し?.value || ''}</div> | |
| </div> | |
| <p class="award-description">${item.内容?.value || ''}</p> | |
| </div> | |
| `, | |
| ) | |
| .join('') || ''; | |
| return ` | |
| <section class="awards-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="awards-container"> | |
| <h2 class="awards-main-title">${section.中区分}</h2> | |
| <div class="awards-content"> | |
| ${items} | |
| </div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // 問題解決セクションの生成 | |
| private generateProblemSolutionSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map((item, index) => { | |
| const imageId = this.generateUniqueImageId('problem', index); | |
| // 変数を即座にキャプチャ | |
| const imageAlt = item.見出し?.value || '画像生成中...'; | |
| const imageTitle = item.見出し?.value?.substring(0, 200) + '...' || ''; | |
| const itemHeading = item.見出し?.value || ''; | |
| const itemContent = item.内容?.value || ''; | |
| const itemNote = item.注釈?.value || ''; | |
| return ` | |
| <div class="strategy-card"> | |
| <img | |
| id="${imageId}" | |
| class="strategy-image" | |
| src="/loading-placeholder.svg" | |
| alt="${imageAlt}" | |
| title="${imageTitle}" | |
| width="800" | |
| height="600" | |
| data-image-status="loading" | |
| data-image-role="illustration" | |
| data-section-type="problem" | |
| style="background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; object-fit: cover; aspect-ratio: 4 / 3;" | |
| loading="lazy" /> | |
| <div class="strategy-content"> | |
| <h3 class="strategy-subtitle">${itemHeading}</h3> | |
| <p class="strategy-text">${itemContent}</p> | |
| ${itemNote ? `<p class="strategy-note">${itemNote}</p>` : ''} | |
| </div> | |
| </div> | |
| `; | |
| }) | |
| .join('') || ''; | |
| return ` | |
| <section class="global-strategy section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="global-strategy-container"> | |
| <h2 class="global-strategy-title">${section.中区分}</h2> | |
| ${items} | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // 利用フローセクションの生成 | |
| private generateUsageFlowSection(section: ContentSection): string { | |
| const steps = | |
| (section.小区分json ?? []) | |
| .map((item, index: number) => { | |
| const imageId = this.generateUniqueImageId('flow', index); | |
| // 動的フィールドをチェック | |
| const itemAny = item as any; | |
| const hasStep = itemAny.ステップ?.value; | |
| const hasIntro = itemAny.導入?.value; | |
| const title = hasStep ? itemAny.ステップ.value : item.見出し?.value || ''; | |
| return ` | |
| <div class="flow-step"> | |
| <div class="flow-number">${String(index + 1).padStart(2, '0')}</div> | |
| <img | |
| id="${imageId}" | |
| class="flow-image" | |
| src="/loading-placeholder.svg" | |
| alt="${title || '画像生成中...'}" | |
| title="${title?.substring(0, 200) + '...' || ''}" | |
| width="800" | |
| height="600" | |
| data-image-status="loading" | |
| data-image-role="illustration" | |
| data-section-type="flow" | |
| style="background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; object-fit: cover; aspect-ratio: 4 / 3;" | |
| loading="lazy" /> | |
| <h3 class="flow-subtitle">${title}</h3> | |
| ${hasIntro ? `<div class="flow-intro">${itemAny.導入.value}</div>` : ''} | |
| <p class="flow-text">${item.内容?.value || ''}</p> | |
| ${item.注釈?.value ? `<p class="flow-note">${item.注釈?.value}</p>` : ''} | |
| </div> | |
| `; | |
| }) | |
| .join('') || ''; | |
| return ` | |
| <section class="usage-flow-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="usage-flow-container"> | |
| <h2 class="usage-flow-title">${section.中区分}</h2> | |
| <div class="usage-flow-content"> | |
| ${steps} | |
| </div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // 商品詳細セクションの生成 | |
| private generateProductDetailsSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map((item, index) => { | |
| const imageId = this.generateUniqueImageId('product', index); | |
| // 変数を即座にキャプチャ | |
| const imageAlt = item.見出し?.value || '画像生成中...'; | |
| const imageTitle = item.見出し?.value?.substring(0, 200) + '...' || ''; | |
| const productSubtitle = item.見出し?.value || ''; | |
| const productText = item.内容?.value || ''; | |
| return ` | |
| <div class="product-detail-item"> | |
| <img | |
| id="${imageId}" | |
| class="product-detail-image" | |
| src="/loading-placeholder.svg" | |
| alt="${imageAlt}" | |
| title="${imageTitle}" | |
| width="800" | |
| height="600" | |
| data-image-status="loading" | |
| data-image-role="illustration" | |
| data-section-type="service" | |
| style="background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; object-fit: cover; aspect-ratio: 4 / 3;" | |
| loading="lazy" /> | |
| <h3 class="product-detail-subtitle">${productSubtitle}</h3> | |
| <p class="product-detail-text">${productText}</p> | |
| </div> | |
| `; | |
| }) | |
| .join('') || ''; | |
| return ` | |
| <section class="product-details-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="product-details-container"> | |
| <h2 class="product-details-title">${section.中区分}</h2> | |
| <div class="product-details-content"> | |
| ${items} | |
| </div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // シミュレーションセクションの生成 | |
| private generateSimulationSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map((item, index) => { | |
| // 動的フィールドをチェック | |
| const itemAny = item as any; | |
| const hasQA = itemAny.質疑?.value; | |
| const hasButton = itemAny.ボタン?.value; | |
| const hasIntro = itemAny.導入?.value; | |
| const title = item.見出し?.value || ''; | |
| const content = hasQA ? itemAny.質疑.value : item.内容?.value || ''; | |
| const intro = hasIntro ? itemAny.導入.value : ''; | |
| const buttonText = hasButton ? itemAny.ボタン.value : ''; | |
| return ` | |
| <div class="simulation-card"> | |
| <h3 class="simulation-title">${title}</h3> | |
| ${intro ? `<div class="simulation-intro">${intro}</div>` : ''} | |
| <div class="simulation-content">${content}</div> | |
| ${buttonText ? `<button class="simulation-button">${buttonText}</button>` : ''} | |
| ${item.注釈?.value ? `<p class="simulation-notes">${item.注釈?.value}</p>` : ''} | |
| </div> | |
| `; | |
| }) | |
| .join('') || ''; | |
| return ` | |
| <section class="simulation-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="simulation-container"> | |
| <h2 class="simulation-title">${section.中区分}</h2> | |
| ${items} | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // 特別オファーセクションの生成 | |
| private generateSpecialOfferSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map((item, index) => { | |
| // 動的フィールドをチェック | |
| const itemAny = item as any; | |
| const hasOfferContent = itemAny['オファー/特典内容']?.value; | |
| const hasButton = itemAny.ボタン?.value; | |
| const hasIntro = itemAny.導入?.value; | |
| const title = item.見出し?.value || ''; | |
| const content = hasOfferContent ? itemAny['オファー/特典内容'].value : item.内容?.value || ''; | |
| const intro = hasIntro ? itemAny.導入.value : ''; | |
| const buttonText = hasButton ? itemAny.ボタン.value : ''; | |
| return ` | |
| <div class="special-offer-card"> | |
| <h3 class="special-offer-title">${title}</h3> | |
| ${intro ? `<div class="special-offer-intro">${intro}</div>` : ''} | |
| <p class="special-offer-content">${content}</p> | |
| ${buttonText ? `<button class="special-offer-button">${buttonText}</button>` : ''} | |
| ${item.注釈?.value ? `<p class="special-offer-notes">${item.注釈?.value}</p>` : ''} | |
| </div> | |
| `; | |
| }) | |
| .join('') || ''; | |
| return ` | |
| <section class="special-offer-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="special-offer-container"> | |
| <h2 class="special-offer-title">${section.中区分}</h2> | |
| ${items} | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // 料金セクションの生成 | |
| private generatePricingSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map((item, index) => { | |
| // 動的フィールドをチェック(互換性のため型アサーションを使用) | |
| const itemAny = item as any; | |
| const hasPrice = itemAny.料金?.value; | |
| const hasPlan = itemAny.プラン名?.value; | |
| // 動的フィールドがある場合は特別な処理 | |
| if (hasPrice || hasPlan) { | |
| return ` | |
| <div class="pricing-card"> | |
| ${ | |
| hasPlan | |
| ? `<h3 class="pricing-card-title">${itemAny.プラン名.value}</h3>` | |
| : `<h3 class="pricing-card-title">${item.見出し?.value || ''}</h3>` | |
| } | |
| ${hasPrice ? `<div class="pricing-card-price">${itemAny.料金.value}</div>` : ''} | |
| <p class="pricing-card-description">${item.内容?.value || ''}</p> | |
| ${item.注釈?.value ? `<p class="pricing-card-description">${item.注釈.value}</p>` : ''} | |
| </div> | |
| `; | |
| } | |
| // 標準フィールドのみの場合 | |
| return ` | |
| <div class="pricing-card"> | |
| <h3 class="pricing-card-title">${item.見出し?.value || ''}</h3> | |
| <p class="pricing-card-description">${item.内容?.value || ''}</p> | |
| ${item.注釈?.value ? `<p class="pricing-card-description">${item.注釈?.value}</p>` : ''} | |
| </div> | |
| `; | |
| }) | |
| .join('') || ''; | |
| return ` | |
| <section class="pricing-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="pricing-container"> | |
| <h2 class="pricing-title">${section.中区分}</h2> | |
| <div class="pricing-cards"> | |
| ${items} | |
| </div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // お客様の声セクションの生成 | |
| private generateTestimonialsSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map( | |
| (item, index) => ` | |
| <div class="review-card"> | |
| <div class="review-header"> | |
| <div class="review-avatar">👤</div> | |
| <div class="review-headline">${item.見出し?.value || ''}</div> | |
| </div> | |
| <p class="review-text">${item.内容?.value || ''}</p> | |
| ${item.ユーザー情報?.value ? `<div class="review-attribution">${item.ユーザー情報?.value}</div>` : ''} | |
| ${item.注釈?.value ? `<p class="review-note">${item.注釈?.value}</p>` : ''} | |
| </div> | |
| `, | |
| ) | |
| .join('') || ''; | |
| return ` | |
| <section class="success-stories-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="success-stories-container"> | |
| <h2 class="success-stories-title">${section.中区分}</h2> | |
| <div class="success-stories-content"> | |
| ${items} | |
| </div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // メディア掲載セクションの生成 | |
| private generateMediaCoverageSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map((item, index) => { | |
| // 動的フィールドをチェック | |
| const itemAny = item as any; | |
| const hasTitle = itemAny.タイトル?.value; | |
| const title = hasTitle ? itemAny.タイトル.value : item.見出し?.value || ''; | |
| return ` | |
| <div class="media-item"> | |
| <h3 class="media-subtitle">${title}</h3> | |
| <p class="media-text">${item.内容?.value || ''}</p> | |
| ${item.注釈?.value ? `<p class="media-note">${item.注釈?.value}</p>` : ''} | |
| </div> | |
| `; | |
| }) | |
| .join('') || ''; | |
| return ` | |
| <section class="media-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="media-container"> | |
| <h2 class="media-title">${section.中区分}</h2> | |
| <div class="media-content"> | |
| ${items} | |
| </div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // 競合比較セクションの生成 | |
| private generateComparisonSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map((item, index) => { | |
| // 動的フィールドをチェック | |
| const itemAny = item as any; | |
| const hasComparisonItem = itemAny.比較項目?.value; | |
| const title = hasComparisonItem ? itemAny.比較項目.value : item.見出し?.value || ''; | |
| return ` | |
| <div class="comparison-item"> | |
| <div class="comparison-icon">🔍</div> | |
| <div class="comparison-item-content"> | |
| <h3 class="comparison-subtitle">${title}</h3> | |
| <p class="comparison-text">${item.内容?.value || ''}</p> | |
| ${item.注釈?.value ? `<p class="comparison-note">${item.注釈?.value}</p>` : ''} | |
| </div> | |
| </div> | |
| `; | |
| }) | |
| .join('') || ''; | |
| return ` | |
| <section class="comparison-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="comparison-container"> | |
| <div class="comparison-content"> | |
| <h2 class="comparison-title">${section.中区分}</h2> | |
| ${items} | |
| </div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // スタッフ紹介セクションの生成 | |
| private generateStaffSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map((item, index) => { | |
| // 動的フィールドをチェック | |
| const itemAny = item as any; | |
| const hasName = itemAny.名前?.value; | |
| const hasIntro = itemAny.導入?.value; | |
| const hasDescription = itemAny.紹介文?.value; | |
| const name = hasName ? itemAny.名前.value : item.ユーザー情報?.value || ''; | |
| const role = item.見出し?.value || ''; | |
| const intro = hasIntro ? itemAny.導入.value : ''; | |
| const bio = hasDescription ? itemAny.紹介文.value : item.内容?.value || ''; | |
| return ` | |
| <div class="staff-member"> | |
| <div class="staff-avatar">👤</div> | |
| <div class="staff-role">${role}</div> | |
| <div class="staff-name">${name}</div> | |
| ${intro ? `<div class="staff-intro">${intro}</div>` : ''} | |
| <p class="staff-bio">${bio}</p> | |
| ${item.注釈?.value ? `<p class="staff-note">${item.注釈?.value}</p>` : ''} | |
| </div> | |
| `; | |
| }) | |
| .join('') || ''; | |
| return ` | |
| <section class="staff-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="staff-container"> | |
| <h2 class="staff-title">${section.中区分}</h2> | |
| <div class="staff-content"> | |
| ${items} | |
| </div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // 店舗情報セクションの生成 | |
| private generateStoreInfoSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map((item, index) => { | |
| // 動的フィールドをチェック | |
| const itemAny = item as any; | |
| const hasStore = itemAny.店舗?.value; | |
| const hasIntro = itemAny.導入?.value; | |
| const hasDetails = itemAny.詳細情報?.value; | |
| const storeName = hasStore ? itemAny.店舗.value : item.見出し?.value || ''; | |
| const description = item.内容?.value || ''; | |
| const intro = hasIntro ? itemAny.導入.value : ''; | |
| const details = hasDetails ? itemAny.詳細情報.value : ''; | |
| // 地図画像用のユニークIDを生成 | |
| const mapImageId = this.generateUniqueImageId('map', index); | |
| // 変数を即座にキャプチャ | |
| const mapAlt = `地図: ${storeName}`; | |
| const mapTitle = `地図: ${storeName}`; | |
| const noteValue = item.注釈?.value || ''; | |
| return ` | |
| <div class="store-item"> | |
| <img | |
| id="${mapImageId}" | |
| class="store-map-image" | |
| src="/loading-placeholder.svg" | |
| alt="${mapAlt}" | |
| title="${mapTitle}" | |
| width="800" | |
| height="600" | |
| data-image-status="loading" | |
| data-image-role="map" | |
| data-section-type="store" | |
| style="background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; object-fit: cover; aspect-ratio: 4 / 3; width: 100%; max-width: 400px;" | |
| loading="lazy" /> | |
| <h3 class="store-name">${storeName}</h3> | |
| ${intro ? `<p class="store-intro">${intro}</p>` : ''} | |
| <p class="store-description">${description}</p> | |
| ${ | |
| details || noteValue | |
| ? ` | |
| <div class="store-info"> | |
| <div class="store-info-item"> | |
| <div class="store-info-label">詳細情報</div> | |
| <div class="store-info-value">${details || noteValue}</div> | |
| </div> | |
| </div>` | |
| : '' | |
| } | |
| </div> | |
| `; | |
| }) | |
| .join('') || ''; | |
| return ` | |
| <section class="store-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="store-container"> | |
| <h2 class="store-title">${section.中区分}</h2> | |
| <div class="store-content"> | |
| ${items} | |
| </div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // 活用シーンセクションの生成 | |
| private generateUseCasesSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map( | |
| (item, index) => ` | |
| <div class="use-case-item"> | |
| <div class="use-case-header">${item.見出し?.value || ''}</div> | |
| <div class="use-case-content"> | |
| <p class="use-case-text">${item.内容?.value || ''}</p> | |
| ${ | |
| item.注釈?.value | |
| ? ` | |
| <div class="use-case-notes"> | |
| ${item.注釈?.value} | |
| </div>` | |
| : '' | |
| } | |
| </div> | |
| </div> | |
| `, | |
| ) | |
| .join('') || ''; | |
| return ` | |
| <section class="use-cases-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="use-cases-container"> | |
| <h2 class="use-cases-title">${section.中区分}</h2> | |
| <div class="use-cases-content"> | |
| ${items} | |
| </div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // FAQ/よくある質問セクションの生成 | |
| private generateFaqSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map((item, index) => { | |
| // 動的フィールドをチェック | |
| const itemAny = item as any; | |
| const hasQA = itemAny.質疑?.value; | |
| if (hasQA) { | |
| // 質疑フィールドがある場合(Q:とA:を分離して処理) | |
| const qaValue = itemAny.質疑.value; | |
| const qaLines = qaValue.split('<BR>').map((line: string) => line.trim()); | |
| let question = ''; | |
| let answer = ''; | |
| // Q:とA:を分離 | |
| qaLines.forEach((line: string) => { | |
| if (line.startsWith('Q:')) { | |
| question = line.substring(2).trim(); | |
| } else if (line.startsWith('A:')) { | |
| answer = line.substring(2).trim(); | |
| } | |
| }); | |
| // 注釈がある場合は追加 | |
| const annotation = itemAny.注釈?.value ? `<p class="faq-annotation">${itemAny.注釈.value}</p>` : ''; | |
| return ` | |
| <div class="faq-item"> | |
| <div class="faq-question"> | |
| <span class="faq-q-mark">Q</span> | |
| <span class="faq-q-text">${question || 'よくあるご質問'}</span> | |
| </div> | |
| <div class="faq-answer"> | |
| <div class="faq-answer-content"> | |
| <span class="faq-a-mark">A</span> | |
| <div class="faq-a-text"> | |
| ${answer || ''} | |
| ${annotation} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| // 標準フィールドのみの場合(見出し・内容) | |
| const questionText = item.見出し?.value || ''; | |
| const answerText = item.内容?.value || ''; | |
| const annotation = item.注釈?.value ? `<p class="faq-annotation">${item.注釈.value}</p>` : ''; | |
| return ` | |
| <div class="faq-item"> | |
| <div class="faq-question"> | |
| <span class="faq-q-mark">Q</span> | |
| <span class="faq-q-text">${questionText}</span> | |
| </div> | |
| <div class="faq-answer"> | |
| <div class="faq-answer-content"> | |
| <span class="faq-a-mark">A</span> | |
| <div class="faq-a-text"> | |
| ${answerText} | |
| ${annotation} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| }) | |
| .join('') || ''; | |
| // FAQセクション用のスタイル(回答を常に表示) | |
| return ` | |
| <section class="faq-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <style> | |
| .faq-section { | |
| padding: 60px 20px; | |
| background: #f8f9fa; | |
| } | |
| .faq-container { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| } | |
| .faq-title { | |
| text-align: center; | |
| font-size: 2.5rem; | |
| color: #333; | |
| margin-bottom: 3rem; | |
| } | |
| .faq-item { | |
| background: white; | |
| border-radius: 8px; | |
| margin-bottom: 1.5rem; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| overflow: hidden; | |
| transition: all 0.3s ease; | |
| } | |
| .faq-item:hover { | |
| box-shadow: 0 4px 8px rgba(0,0,0,0.15); | |
| } | |
| .faq-question { | |
| padding: 1.5rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| background: white; | |
| border-bottom: 2px solid #f0f0f0; | |
| } | |
| .faq-q-mark { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 32px; | |
| height: 32px; | |
| background: #5a6c7d; | |
| color: white; | |
| border-radius: 50%; | |
| font-weight: bold; | |
| flex-shrink: 0; | |
| } | |
| .faq-q-text { | |
| flex: 1; | |
| font-size: 1.1rem; | |
| color: #333; | |
| font-weight: 500; | |
| } | |
| .faq-answer { | |
| background: #fafafa; | |
| display: block; | |
| } | |
| .faq-answer-content { | |
| padding: 1.5rem; | |
| display: flex; | |
| gap: 1rem; | |
| } | |
| .faq-a-mark { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 32px; | |
| height: 32px; | |
| background: #5a6c7d; | |
| color: white; | |
| border-radius: 50%; | |
| font-weight: bold; | |
| flex-shrink: 0; | |
| } | |
| .faq-a-text { | |
| flex: 1; | |
| font-size: 1rem; | |
| color: #555; | |
| line-height: 1.6; | |
| } | |
| .faq-annotation { | |
| margin-top: 1rem; | |
| padding-top: 1rem; | |
| border-top: 1px solid #e0e0e0; | |
| font-size: 0.9rem; | |
| color: #777; | |
| font-style: italic; | |
| } | |
| </style> | |
| <div class="faq-container"> | |
| <h2 class="faq-title">${section.中区分}</h2> | |
| <div class="faq-content"> | |
| ${items} | |
| </div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // News/更新情報セクションの生成 | |
| private generateNewsUpdatesSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map((item, index) => { | |
| // 動的フィールドをチェック | |
| const itemAny = item as any; | |
| const hasTitle = itemAny.タイトル?.value; | |
| const title = hasTitle ? itemAny.タイトル.value : item.見出し?.value || ''; | |
| return ` | |
| <div class="news-item"> | |
| <div class="news-header"> | |
| <div class="news-date">2024.12.${String(15 - index).padStart(2, '0')}</div> | |
| <div class="news-category">プレスリリース</div> | |
| </div> | |
| <h3 class="news-title-text">${title}</h3> | |
| <p class="news-summary">${item.内容?.value || ''}</p> | |
| ${item.注釈?.value ? `<p class="news-note">${item.注釈?.value}</p>` : ''} | |
| </div> | |
| `; | |
| }) | |
| .join('') || ''; | |
| return ` | |
| <section class="news-section section-base" id="${this.mapSectionType(section.大区分)}" aria-label="${ariaLabelMapping[section.大区分]}"> | |
| <div class="news-container"> | |
| <h2 class="news-title">${section.中区分}</h2> | |
| <div class="news-content"> | |
| ${items} | |
| </div> | |
| <div class="news-more"> | |
| <a href="#" class="news-more-button">すべてのニュースを見る</a> | |
| </div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // 汎用セクションの生成 | |
| private generateGenericSection(section: ContentSection): string { | |
| const items = | |
| (section.小区分json ?? []) | |
| .map( | |
| (item, index) => ` | |
| <div class="content-item"> | |
| <h3>${item.見出し?.value || ''}</h3> | |
| <p>${item.内容?.value || ''}</p> | |
| ${item.注釈?.value ? `<small>${item.注釈?.value}</small>` : ''} | |
| </div> | |
| `, | |
| ) | |
| .join('') || ''; | |
| return ` | |
| <section class="content-section section-base" data-section-type="その他"> | |
| <div style="max-width: 1200px; margin: 0 auto; padding: 0 20px;"> | |
| <h2>${section.中区分}</h2> | |
| ${items} | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // 権威付けセクションの生成(ヒーローセクション用) | |
| private generateAuthoritySection(authority?: { 内容: string }): string { | |
| if (!authority) return ''; | |
| return ` | |
| <section class="special-offer-section section-base"> | |
| <div class="special-offer-card"> | |
| <div class="special-offer-intro">${authority.内容}</div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| // フッターセクションの生成 | |
| private generateFooterSection(companyName?: string): string { | |
| return ` | |
| <footer class="footer"> | |
| <div class="back-to-top"> | |
| <button onclick="scrollToTop()" aria-label="ページトップへ戻る"> | |
| <span>▲</span> | |
| </button> | |
| </div> | |
| <div class="footer-content"> | |
| <div class="social-icons"> | |
| <a href="#" aria-label="Facebook" | |
| ><div class="social-icon">f</div></a | |
| > | |
| <a href="#" aria-label="YouTube" | |
| ><div class="social-icon">▶</div></a | |
| > | |
| <a href="#" aria-label="LinkedIn" | |
| ><div class="social-icon">in</div></a | |
| > | |
| </div> | |
| <div class="footer-nav"> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >サービス <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >ナレッジ <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >イベント&セミナー <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >採用 <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >ニュース <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >サステナビリティ <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >会社情報 <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >お問い合わせ <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| </div> | |
| <div class="footer-links"> | |
| <a href="#" class="footer-link">サイトマップ</a> | |
| <a href="#" class="footer-link">個人情報保護方針</a> | |
| <a href="#" class="footer-link">特定個人情報基本方針</a> | |
| <a href="#" class="footer-link">ご利用にあたって</a> | |
| </div> | |
| <div class="copyright"> | |
| Copyright© All Rights Reserved. | |
| </div> | |
| </div> | |
| </footer> | |
| `; | |
| } | |
| // template-generator.htmlファイルからテンプレートを読み込み | |
| private loadTemplateFromFile(): string { | |
| try { | |
| const templatePath = join(process.cwd(), 'server', 'lib', 'templates', 'template-generator.html'); | |
| return readFileSync(templatePath, 'utf-8'); | |
| } catch (error) { | |
| console.error('template-generator.htmlの読み込みに失敗しました:', error); | |
| // フォールバック用の簡単なテンプレート | |
| return this.getFallbackTemplate(); | |
| } | |
| } | |
| // フォールバック用テンプレート | |
| private getFallbackTemplate(): string { | |
| return `<!DOCTYPE html> | |
| <html lang="ja"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>{{COMPANY_TITLE}}</title> | |
| </head> | |
| <body> | |
| <header class="header"> | |
| <div class="logo">{{COMPANY_NAME}}</div> | |
| </header> | |
| <section class="hero-section"> | |
| <div class="hero-container"> | |
| <h1 class="hero-title">{{HERO_MAIN_COPY}}</h1> | |
| <div class="features"> | |
| <div class="features-container"> | |
| {{HERO_FEATURES}} | |
| </div> | |
| </div> | |
| <div class="cta-section"> | |
| <p class="cta-subtitle">{{CTA_MICRO_COPY}}</p> | |
| <a href="#" class="cta-button">{{CTA_BUTTON_TEXT}}</a> | |
| </div> | |
| </div> | |
| </section> | |
| {{CONTENT_SECTIONS}} | |
| {{AUTHORITY_SECTION}} | |
| <footer class="footer"> | |
| <div class="back-to-top"> | |
| <button onclick="scrollToTop()" aria-label="ページトップへ戻る"> | |
| <span>▲</span> | |
| </button> | |
| </div> | |
| <div class="footer-content"> | |
| <div class="social-icons"> | |
| <a href="#" aria-label="Facebook" | |
| ><div class="social-icon">f</div></a | |
| > | |
| <a href="#" aria-label="YouTube" | |
| ><div class="social-icon">▶</div></a | |
| > | |
| <a href="#" aria-label="LinkedIn" | |
| ><div class="social-icon">in</div></a | |
| > | |
| </div> | |
| <div class="footer-nav"> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >サービス <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >ナレッジ <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >イベント&セミナー <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >採用 <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >ニュース <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >サステナビリティ <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >会社情報 <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| <div class="footer-nav-item"> | |
| <a href="#" class="footer-nav-link" | |
| >お問い合わせ <span class="arrow">❯</span></a | |
| > | |
| </div> | |
| </div> | |
| <div class="footer-links"> | |
| <a href="#" class="footer-link">サイトマップ</a> | |
| <a href="#" class="footer-link">個人情報保護方針</a> | |
| <a href="#" class="footer-link">特定個人情報基本方針</a> | |
| <a href="#" class="footer-link">ご利用にあたって</a> | |
| </div> | |
| <div class="copyright"> | |
| Copyright© {{COMPANY_NAME}}. All Rights Reserved. | |
| </div> | |
| </div> | |
| </footer> | |
| </body> | |
| </html>`; | |
| } | |
| } | |