diff --git a/README.md b/README.md index 316236f..b073b51 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,32 @@ node scripts/fetch-imagery.mjs **مجوز:** استفادهٔ تجاری و میزبانی نسخه‌ای از تصاویر Esri نیاز به مجوز مناسب از Esri دارد. شرایط را با Esri بررسی کنید. نام منبع در پایین صفحهٔ بازی نمایش داده می‌شود. +## جاسازی در سایت شرکت + +بازی کل صفحه را می‌گیرد، پس بهترین راه جاسازی یک صفحهٔ جدا یا یک `iframe` است: + +```html + +``` + +- `?poster`: اول یک پوستر سبک با دکمهٔ «ورود به بازی» نشان داده می‌شود و بازی سه‌بعدی فقط با کلیک بارگذاری می‌شود. این برای صفحهٔ اصلی سایت مناسب است. +- `?mode=real|offline|about|products`: بازی بدون صفحهٔ شروع، مستقیم در همان حالت شروع می‌شود. حالت دنیای واقعی همچنان اول پیام ایمنی را نشان می‌دهد. + +در یک پروژهٔ React همین کار با کامپوننت انجام می‌شود: + +```jsx +import { CompanyGame } from '@/pages/game/CompanyGame.jsx'; + +; +``` + +- `CompanyGame` استور Redux، صفحهٔ بارگذاری با نوار پیشرفت، پوستر و حالت بدون WebGL را خودش دارد. +- اگر مرورگر WebGL نداشته باشد، به‌جای بازی پیام کوتاه و اطلاعات شرکت (معرفی، تماس و آدرس) نشان داده می‌شود. +- رنگ‌ها و اطلاعات برند از `src/config/company.js` می‌آیند، نه از props، چون مدل‌های سه‌بعدی هم از همان رنگ‌ها ساخته می‌شوند. +- عنوان صفحه، توضیح، دادهٔ ساخت‌یافتهٔ Organization و یک خلاصهٔ فارسی پنهان (برای موتورهای جست‌وجو) هنگام ساخت از همان فایل در `index.html` نوشته می‌شوند (`src/utils/seoSummary.js`). +- برای کارایی: صفحهٔ بازی و Three.js یک بستهٔ جدا هستند، تراکم پیکسل روی گوشی ۱٫۵ و روی دسکتاپ ۱٫۷۵ محدود است، و هنگام بستن بازی همهٔ هندسه‌ها، متریال‌ها، بافت‌ها، تایمرها و حسگرها آزاد می‌شوند. + ## ساختار - `src/config/company.js`: همهٔ اطلاعات شرکت در یک جا: نام، شعار، رنگ‌های برند (رنگ اصلی `#00a693` و طیف آن)، آدرس، تلفن، ایمیل، شبکه‌های اجتماعی، درباره‌ما و فهرست محصولات. مقدارهای `TODO(company)` جایگزین‌شدنی‌اند. diff --git a/index.html b/index.html index 9bbd831..215f6b5 100644 --- a/index.html +++ b/index.html @@ -4,11 +4,8 @@ - عملیات مشهد | روبات مارکت - + + @@ -18,6 +15,7 @@ +
diff --git a/src/constants/graphics.js b/src/constants/graphics.js new file mode 100644 index 0000000..5055e4e --- /dev/null +++ b/src/constants/graphics.js @@ -0,0 +1,5 @@ +// Device pixel ratio caps. Rendering at the full ratio of a 3× phone screen costs nine times the +// pixels for little visible gain, so it is capped; the low setting renders at 1× for weak devices. +export const PIXEL_RATIO_LOW = 1; +export const PIXEL_RATIO_MOBILE = 1.5; +export const PIXEL_RATIO_DESKTOP = 1.75; diff --git a/src/game/createGame.js b/src/game/createGame.js index 4cb3094..79cf43b 100644 --- a/src/game/createGame.js +++ b/src/game/createGame.js @@ -1,19 +1,24 @@ import { bindRuntime, RUNTIME_INITIALIZERS } from './runtime/runtimeBindings.js'; /** Starts isolated systems in dependency order and returns their shared lifecycle cleanup. - * @param {{root:HTMLElement,ui:object,signal:AbortSignal}} options - React mount context. + * Reports start-up progress (0–1) on the `loading` interface node as each system is ready. + * @param {{root:HTMLElement,ui:object,signal:AbortSignal,startMode?:string}} options - React + * mount context, and a start mode that skips the start screen. * @returns {Promise} Disposes input, timers, sensors and graphics. */ -export async function createGame({ root, ui, signal }) { +export async function createGame({ root, ui, signal, startMode }) { const context = { root, ui, signal, + startMode: null, + requestedMode: startMode ?? null, }; bindRuntime(context); try { - for (const initialize of RUNTIME_INITIALIZERS) { + for (const [index, initialize] of RUNTIME_INITIALIZERS.entries()) { if (signal.aborted) break; await initialize(context); + ui.patch('loading', { progress: (index + 1) / RUNTIME_INITIALIZERS.length }); } } catch (error) { context.renderer?.dispose(); diff --git a/src/game/device/webgl.js b/src/game/device/webgl.js new file mode 100644 index 0000000..b56c8c9 --- /dev/null +++ b/src/game/device/webgl.js @@ -0,0 +1,15 @@ +/** + * Whether this browser can draw the 3D world. Creates a throwaway canvas and releases its context + * straight away, so the check costs nothing once the answer is known. + * @returns {boolean} True when a WebGL context is available. + */ +export function supportsWebgl() { + try { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl'); + gl?.getExtension('WEBGL_lose_context')?.loseContext(); + return Boolean(gl); + } catch { + return false; + } +} diff --git a/src/game/runtime/runtimeSetup.js b/src/game/runtime/runtimeSetup.js index 8fc64ee..dd4054c 100644 --- a/src/game/runtime/runtimeSetup.js +++ b/src/game/runtime/runtimeSetup.js @@ -27,6 +27,11 @@ import { formatDistance, toLocal } from '../../utils/geo.js'; import { strings } from '../../i18n/fa.js'; import { COMPANY } from '../../config/company.js'; import { SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js'; +import { + PIXEL_RATIO_DESKTOP, + PIXEL_RATIO_LOW, + PIXEL_RATIO_MOBILE, +} from '../../constants/graphics.js'; import { createObjectiveArrow } from '../models/objectiveArrow.js'; // Brand colours for materials and canvas drawings. @@ -95,7 +100,10 @@ export async function initializeRenderer(context) { } })() ?? (matchMedia('(pointer: coarse)').matches ? 'low' : 'high'); - context.renderer.setPixelRatio(context.quality === 'low' ? 1 : Math.min(devicePixelRatio, 1.75)); + const cap = matchMedia('(pointer: coarse)').matches ? PIXEL_RATIO_MOBILE : PIXEL_RATIO_DESKTOP; + context.renderer.setPixelRatio( + context.quality === 'low' ? PIXEL_RATIO_LOW : Math.min(devicePixelRatio, cap), + ); } /** * Initializes scene in dependency order. @@ -1247,6 +1255,10 @@ export async function startGameLoop(context) { hidden: true, }); context.frameId = requestAnimationFrame(context.frame); + // An embedding page may ask for a mode directly (CompanyGame's startMode, or ?mode=). Real-world + // play still shows its safety notice first, and asks for location from the tap on it. + if (context.requestedMode === 'real') context.askRealMode(true); + else if (context.requestedMode) context.startGame(context.requestedMode); } /** * Initializes dispose in dependency order. diff --git a/src/hooks/useGameEngine.js b/src/hooks/useGameEngine.js index 0e439b6..f860ed5 100644 --- a/src/hooks/useGameEngine.js +++ b/src/hooks/useGameEngine.js @@ -3,9 +3,10 @@ import { useEffect, useRef, useState } from 'react'; /** Mounts one game runtime and releases its GPU, input, sensor and timer resources. * @param {object} root - React ref containing all render surfaces. * @param {object} bridge - Interface publisher owned by this page. + * @param {string} [startMode] - Mode to start in directly, skipping the start screen. * @returns {{error:string|null}} Startup failure for the accessible loading surface. */ -export function useGameEngine(root, bridge) { +export function useGameEngine(root, bridge, startMode) { const [error, setError] = useState(null); const disposeRef = useRef(null); useEffect(() => { @@ -23,6 +24,7 @@ export function useGameEngine(root, bridge) { root: root.current, ui: bridge, signal: controller.signal, + startMode, }); if (controller.signal.aborted) dispose(); else disposeRef.current = dispose; @@ -40,7 +42,7 @@ export function useGameEngine(root, bridge) { disposeRef.current?.(); disposeRef.current = null; }; - }, [root, bridge]); + }, [root, bridge, startMode]); return { error, }; diff --git a/src/i18n/fa.js b/src/i18n/fa.js index 7a9252e..a3e54c1 100644 --- a/src/i18n/fa.js +++ b/src/i18n/fa.js @@ -31,9 +31,24 @@ export const strings = { hetomanName: 'هتومان', speed: 'km/h', }, + // Search-engine text written into index.html at build time (utils/seoSummary.js). + seo: { + title: `${COMPANY.name} | بازی سه‌بعدی دستگاه‌های وندینگ`, + description: `${COMPANY.tagline}. کارخانه، محصولات و دستگاه‌های نصب‌شدهٔ ${COMPANY.shortName} را در یک بازی سه‌بعدی در مشهد ببینید.`, + summaryLabel: 'خلاصهٔ معرفی شرکت', + productsHeading: 'محصولات', + }, + // Poster shown before the 3D game loads, and instead of it when the browser cannot draw 3D. + poster: { + enter: 'ورود به بازی', + noWebgl: + 'مرورگر یا دستگاه شما بازی سه‌بعدی را اجرا نمی‌کند. اطلاعات شرکت را این‌جا ببینید یا با مرورگر دیگری امتحان کنید.', + heroAlt: `نمای کارخانهٔ ${COMPANY.shortName}`, + }, app: { - preparing: 'در حال آماده‌سازی بازی…', buildingCity: 'در حال ساختن شهر مشهد…', + /** @type {(percent: string) => string} */ + loadingPercent: (percent) => `${percent}٪`, /** @type {(error: string) => string} */ failed: (error) => `بازی اجرا نشد: ${error}`, retry: 'تلاش دوباره', diff --git a/src/main.jsx b/src/main.jsx index ae84265..4d19b11 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,13 +1,8 @@ import { createRoot } from 'react-dom/client'; -import { Provider } from 'react-redux'; import { AppRoutes } from '@/routes/AppRoutes.jsx'; -import { store } from '@/store/index.js'; import '@fontsource/vazirmatn/400.css'; import '@fontsource/vazirmatn/600.css'; import '@fontsource/vazirmatn/800.css'; import '@/styles/game.css'; -createRoot(document.getElementById('root')).render( - - - , -); +// The store is provided inside CompanyGame, so the game can also be mounted on its own. +createRoot(document.getElementById('root')).render(); diff --git a/src/pages/game/CompanyGame.jsx b/src/pages/game/CompanyGame.jsx new file mode 100644 index 0000000..7e059f5 --- /dev/null +++ b/src/pages/game/CompanyGame.jsx @@ -0,0 +1,39 @@ +import { Suspense, lazy, useState } from 'react'; +import { Provider } from 'react-redux'; +import { store } from '@/store/index.js'; +import { supportsWebgl } from '@/game/device/webgl.js'; +import { GamePoster } from './GamePoster.jsx'; +import { GameLoader } from './GameLoader.jsx'; + +// The game page (and Three.js with it) is a separate chunk, fetched only when the game starts. +const GamePage = lazy(() => + import('./GamePage.jsx').then((module) => ({ default: module.GamePage })), +); + +/** + * @typedef {object} CompanyGameProps + * @property {'real' | 'offline' | 'about' | 'products'} [startMode] - Start straight in this mode + * instead of showing the start screen. + * @property {boolean} [showPoster] - Show a light poster with an "Enter the game" button first, + * so the 3D game only loads when the visitor asks (good for landing pages). Default false. + */ +/** + * The whole company game as one self-contained component: store, loader, poster, no-WebGL + * fallback and the game itself. Mount it on a full-page route (or in an iframe); the game uses + * the whole viewport. + * @param {CompanyGameProps} props - Embedding options. + * @returns {import('react').ReactElement} Game, poster or fallback. + */ +export function CompanyGame({ startMode, showPoster = false }) { + const [canRender] = useState(supportsWebgl); + const [entered, setEntered] = useState(!showPoster); + if (!canRender || !entered) + return setEntered(true)} />; + return ( + + }> + + + + ); +} diff --git a/src/pages/game/GameLoader.jsx b/src/pages/game/GameLoader.jsx new file mode 100644 index 0000000..07bcfd4 --- /dev/null +++ b/src/pages/game/GameLoader.jsx @@ -0,0 +1,41 @@ +import { COMPANY } from '@/config/company.js'; +import { ASSETS } from '@/config/assets.js'; +import { strings } from '@/i18n/fa.js'; + +/** @typedef {{nodes: object}} GameLoaderProps */ +/** + * Branded loading screen with a progress bar, shown while the 3D world is being built. + * @param {GameLoaderProps} props - Interface snapshots; `loading` carries progress (0–1). + * @returns {import('react').ReactElement} Loading screen. + */ +export function GameLoader({ nodes }) { + const loading = nodes.loading ?? {}; + const percent = Math.round((loading.progress ?? 0) * 100); + return ( + + ); +} diff --git a/src/pages/game/GamePage.jsx b/src/pages/game/GamePage.jsx index 99d874c..5684e1b 100644 --- a/src/pages/game/GamePage.jsx +++ b/src/pages/game/GamePage.jsx @@ -6,18 +6,23 @@ import { createInterfaceBridge } from '@/game/interfaceBridge.js'; import { useGameEngine } from '@/hooks/useGameEngine.js'; import { setupPwa } from '@/game/device/pwa.js'; import { GameSurfaces } from './GameSurfaces.jsx'; +import { GameLoader } from './GameLoader.jsx'; import { StartScreen } from './startScreen/StartScreen.jsx'; import { GameHud } from './GameHud.jsx'; import { GameDialogs } from './GameDialogs.jsx'; import { strings } from '@/i18n/fa.js'; -/** Composes React views around the isolated Three.js runtime. */ -export function GamePage() { +/** + * Composes React views around the isolated Three.js runtime. + * @param {{startMode?: string}} props - Mode to start in directly, skipping the start screen. + * @returns {import('react').ReactElement} Game page. + */ +export function GamePage({ startMode }) { const root = useRef(null), store = useStore(); const bridge = useMemo(() => createInterfaceBridge(store), [store]); const nodes = useAppSelector(selectNodes); - const { error } = useGameEngine(root, bridge); + const { error } = useGameEngine(root, bridge, startMode); useEffect(() => { bridge.activate(); const dispose = setupPwa(bridge); @@ -41,6 +46,7 @@ export function GamePage() { return (
+ diff --git a/src/pages/game/GamePoster.jsx b/src/pages/game/GamePoster.jsx new file mode 100644 index 0000000..6ddb551 --- /dev/null +++ b/src/pages/game/GamePoster.jsx @@ -0,0 +1,73 @@ +import { COMPANY } from '@/config/company.js'; +import { ASSETS } from '@/config/assets.js'; +import { strings } from '@/i18n/fa.js'; +import { SECTION_BODIES } from './startScreen/InfoSectionContent.jsx'; + +// Company sections the poster shows when the 3D game cannot run. +const FALLBACK_SECTIONS = ['about', 'contact', 'address']; + +/** @typedef {{canRender: boolean, onEnter: function}} GamePosterProps */ +/** + * Light poster in place of the 3D game: a hero image and an "Enter the game" button, so an + * embedding page loads nothing heavy until the visitor asks. When the browser cannot draw 3D it + * explains that and shows the company information instead. + * @param {GamePosterProps} props - Whether WebGL works, and the callback that starts the game. + * @returns {import('react').ReactElement} Poster. + */ +export function GamePoster({ canRender, onEnter }) { + return ( +
+
+ {strings.poster.heroAlt} +
+
+

{COMPANY.name}

+

{COMPANY.tagline}

+
+
+
+
+

{strings.landing.intro}

+ {canRender ? ( + + ) : ( + + )} +
+
+ ); +} + +/** + * Company information for browsers without 3D support. + * @returns {import('react').ReactElement} Notice and sections. + */ +function FallbackInfo() { + return ( + <> +

+ {strings.poster.noWebgl} +

+ {FALLBACK_SECTIONS.map((key) => { + const Body = SECTION_BODIES[key]; + const title = key === 'about' ? strings.landing.history : strings.landing.sections[key]; + return ( +
+

{title}

+ +
+ ); + })} + + ); +} diff --git a/src/pages/game/GamePoster.test.jsx b/src/pages/game/GamePoster.test.jsx new file mode 100644 index 0000000..782069c --- /dev/null +++ b/src/pages/game/GamePoster.test.jsx @@ -0,0 +1,33 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { COMPANY } from '@/config/company.js'; +import { strings } from '@/i18n/fa.js'; +import { GamePoster } from './GamePoster.jsx'; +import { GameLoader } from './GameLoader.jsx'; + +afterEach(cleanup); + +describe('GamePoster', () => { + it('starts the game from the enter button when 3D works', () => { + const onEnter = vi.fn(); + render(); + expect(screen.getByText(COMPANY.name)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: strings.poster.enter })); + expect(onEnter).toHaveBeenCalled(); + }); + + it('explains missing 3D support and shows the company information instead', () => { + render(); + expect(screen.getByRole('alert')).toHaveTextContent(strings.poster.noWebgl); + expect(screen.queryByRole('button', { name: strings.poster.enter })).not.toBeInTheDocument(); + expect(screen.getByText(COMPANY.about.history)).toBeInTheDocument(); + expect(screen.getByText(COMPANY.contact.email)).toBeInTheDocument(); + }); +}); + +describe('GameLoader', () => { + it('shows start-up progress as a percentage', () => { + render(); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '42'); + }); +}); diff --git a/src/pages/game/GameSurfaces.jsx b/src/pages/game/GameSurfaces.jsx index 036dac6..d886026 100644 --- a/src/pages/game/GameSurfaces.jsx +++ b/src/pages/game/GameSurfaces.jsx @@ -1,5 +1,4 @@ import { GameNode } from '@/components/shared/GameNode.jsx'; -import { strings } from '@/i18n/fa.js'; /** @typedef {{nodes:object,actions:object}} GameSurfacesProps */ /** Presents the GameSurfaces view using plain models and semantic native elements. * @param {GameSurfacesProps} props - Interface snapshots and callbacks. @@ -18,9 +17,6 @@ export function GameSurfaces({ nodes }) { model={nodes['camera']} > - - {strings.app.buildingCity} - ); } diff --git a/src/routes/AppRoutes.jsx b/src/routes/AppRoutes.jsx index c4737ad..1757834 100644 --- a/src/routes/AppRoutes.jsx +++ b/src/routes/AppRoutes.jsx @@ -1,15 +1,21 @@ -import { Suspense, lazy } from 'react'; -import { strings } from '@/i18n/fa.js'; -const GamePage = lazy(() => - import('@/pages/game/GamePage.jsx').then((module) => ({ - default: module.GamePage, - })), -); -/** Loads the one game page on demand while keeping a visible startup status. */ +import { CompanyGame } from '@/pages/game/CompanyGame.jsx'; +import { START_MODES } from '@/constants/startModes.js'; + +/** + * Reads the embedding options from the address: `?mode=about` starts in a mode, `?poster` shows + * the light poster first. An iframe on the company site can set them in its src. + * @returns {{startMode?: string, showPoster: boolean}} CompanyGame props. + */ +function optionsFromUrl() { + const params = new URLSearchParams(location.search); + const mode = params.get('mode'); + return { + startMode: START_MODES.includes(mode) ? mode : undefined, + showPoster: params.has('poster'), + }; +} + +/** The app has one page: the company game, configured from the address. */ export function AppRoutes() { - return ( - {strings.app.preparing}

}> - -
- ); + return ; } diff --git a/src/utils/seoSummary.js b/src/utils/seoSummary.js new file mode 100644 index 0000000..5881480 --- /dev/null +++ b/src/utils/seoSummary.js @@ -0,0 +1,78 @@ +import { COMPANY } from '../config/company.js'; +import { strings } from '../i18n/fa.js'; + +// Builds the search-engine content written into index.html at build time (see vite.config.js): +// the page title and description, a visually hidden Persian summary of the company and its +// products, and Organization structured data. Crawlers read it without running the 3D game. + +/** + * Escapes text for use inside HTML. + * @param {string} text - Plain text. + * @returns {string} HTML-safe text. + */ +const escapeHtml = (text) => + String(text).replace( + /[&<>"']/g, + (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch], + ); + +/** + * Title and description tags. + * @returns {string} HTML for the head. + */ +export function seoHead() { + return [ + `${escapeHtml(strings.seo.title)}`, + ``, + ].join('\n '); +} + +/** + * Visually hidden summary of the company, its products and contact details. + * @returns {string} HTML for the body. + */ +export function seoSummary() { + const { contact, about, products } = COMPANY; + const items = products + .map((p) => `
  • ${escapeHtml(p.name)}

    ${escapeHtml(p.description)}

  • `) + .join(''); + const address = strings.landing.fullAddress(contact.address, contact.city); + return `
    +

    ${escapeHtml(COMPANY.name)}

    +

    ${escapeHtml(COMPANY.tagline)}

    +

    ${escapeHtml(about.history)}

    +

    ${escapeHtml(strings.seo.productsHeading)}

    +
      ${items}
    +

    ${escapeHtml(strings.landing.sections.contact)}

    +

    ${escapeHtml(address)}

    +

    ${escapeHtml(`${strings.landing.phone}: ${contact.phone}`)}

    +

    ${escapeHtml(`${strings.landing.email}: ${contact.email}`)}

    +
    `; +} + +/** + * Organization structured data (schema.org JSON-LD). + * @returns {string} Script tag. + */ +export function seoStructuredData() { + const { contact } = COMPANY; + const data = { + '@context': 'https://schema.org', + '@type': 'Organization', + name: COMPANY.name, + description: COMPANY.tagline, + url: contact.website, + email: contact.email, + telephone: contact.phone, + address: { + '@type': 'PostalAddress', + streetAddress: contact.address, + addressLocality: contact.city, + addressCountry: 'IR', + }, + sameAs: contact.socials.map((social) => social.url), + }; + // "<" is escaped so no value can close the script tag early. + const json = JSON.stringify(data).replace(/${json}`; +} diff --git a/tests/seo.test.js b/tests/seo.test.js new file mode 100644 index 0000000..10989d0 --- /dev/null +++ b/tests/seo.test.js @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { COMPANY } from '../src/config/company.js'; +import { seoHead, seoStructuredData, seoSummary } from '../src/utils/seoSummary.js'; + +test('The page head carries the company title and description', () => { + const head = seoHead(); + assert.ok(head.includes(`${COMPANY.name}`)); + assert.match(head, /<meta name="description" content="[^"]+"/); +}); + +test('The hidden summary lists the company, every product and the contact details', () => { + const summary = seoSummary(); + assert.ok(summary.startsWith('<section class="sr-only"')); + for (const product of COMPANY.products) assert.ok(summary.includes(product.name)); + assert.ok(summary.includes(COMPANY.contact.email)); +}); + +test('Structured data is valid JSON-LD for the organization', () => { + const json = seoStructuredData().replace(/^<script[^>]*>|<\/script>$/g, ''); + const data = JSON.parse(json); + assert.equal(data['@type'], 'Organization'); + assert.equal(data.name, COMPANY.name); + assert.equal(data.address.addressLocality, COMPANY.contact.city); +}); diff --git a/vite.config.js b/vite.config.js index 40a89c2..4322b88 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,6 +4,7 @@ import { defineConfig } from 'vite'; import { readdirSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import { createHash } from 'node:crypto'; +import { seoHead, seoStructuredData, seoSummary } from './src/utils/seoSummary.js'; const listFiles = (dir) => readdirSync(dir).flatMap((name) => { @@ -62,11 +63,23 @@ self.addEventListener('fetch', event => { }; } +// Writes the company's title, description, structured data and a hidden Persian summary into +// index.html, so search engines and link previews see real content without running the game. +function companySeo() { + return { + name: 'company-seo', + transformIndexHtml: (html) => + html + .replace('<!--seo-head-->', `${seoHead()}\n ${seoStructuredData()}`) + .replace('<!--seo-summary-->', seoSummary()), + }; +} + export default defineConfig({ // Relative assets also load under a GitHub Pages repository path. base: './', resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, - plugins: [react(), offlineServiceWorker()], + plugins: [react(), companySeo(), offlineServiceWorker()], // Top-level await loads the building and imagery files before the world is built. build: { target: 'es2022' }, server: {