feat: embeddable CompanyGame with loader, poster, no-WebGL fallback and SEO
CI/CD / test (push) Successful in 1m10s
CI/CD / publish (push) Successful in 1m20s
CI/CD / deploy (push) Successful in 6s

- CompanyGame bundles the store, a branded loader with start-up progress, an optional
  light poster (Enter the game) and a company-info fallback when WebGL is missing.
- startMode prop and ?mode= start straight in a mode; ?poster shows the poster first.
- Pixel ratio capped at 1.5 on touch devices and 1.75 on desktop (constants/graphics.js).
- Build writes the title, description, Organization JSON-LD and a hidden Persian
  summary into index.html from the company config.
- README explains iframe and component embedding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 13:26:30 +03:00
co-authored by Claude Opus 5.5
parent 53caa0a208
commit bc6f472f08
19 changed files with 423 additions and 40 deletions
+26
View File
@@ -237,6 +237,32 @@ node scripts/fetch-imagery.mjs
**مجوز:** استفادهٔ تجاری و میزبانی نسخه‌ای از تصاویر Esri نیاز به مجوز مناسب از Esri دارد. شرایط را با Esri بررسی کنید. نام منبع در پایین صفحهٔ بازی نمایش داده می‌شود.
## جاسازی در سایت شرکت
بازی کل صفحه را می‌گیرد، پس بهترین راه جاسازی یک صفحهٔ جدا یا یک `iframe` است:
```html
<iframe src="https://robotmarkets.ir/game/?poster&mode=about" title="بازی روبات مارکت"
style="width:100%;height:100dvh;border:0" allow="fullscreen; geolocation; camera"></iframe>
```
- `?poster`: اول یک پوستر سبک با دکمهٔ «ورود به بازی» نشان داده می‌شود و بازی سه‌بعدی فقط با کلیک بارگذاری می‌شود. این برای صفحهٔ اصلی سایت مناسب است.
- `?mode=real|offline|about|products`: بازی بدون صفحهٔ شروع، مستقیم در همان حالت شروع می‌شود. حالت دنیای واقعی همچنان اول پیام ایمنی را نشان می‌دهد.
در یک پروژهٔ React همین کار با کامپوننت انجام می‌شود:
```jsx
import { CompanyGame } from '@/pages/game/CompanyGame.jsx';
<CompanyGame startMode="products" showPoster />;
```
- `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)` جایگزین‌شدنی‌اند.
+3 -5
View File
@@ -4,11 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<meta name="theme-color" content="#0b1614" />
<title>عملیات مشهد | روبات مارکت</title>
<meta
name="description"
content="از کارخانهٔ روبات مارکت تا بوستان ملت: دستگاه‌های وندینگ را در مشهد پیدا کن و امتیاز بگیر."
/>
<!-- Title, description and structured data come from the company config (vite.config.js). -->
<!--seo-head-->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
@@ -18,6 +15,7 @@
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />
</head>
<body>
<!--seo-summary-->
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
+5
View File
@@ -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;
+8 -3
View File
@@ -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<function>} 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();
+15
View File
@@ -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;
}
}
+13 -1
View File
@@ -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.
+4 -2
View File
@@ -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,
};
+16 -1
View File
@@ -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: 'تلاش دوباره',
+2 -7
View File
@@ -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(
<Provider store={store}>
<AppRoutes />
</Provider>,
);
// The store is provided inside CompanyGame, so the game can also be mounted on its own.
createRoot(document.getElementById('root')).render(<AppRoutes />);
+39
View File
@@ -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 <GamePoster canRender={canRender} onEnter={() => setEntered(true)} />;
return (
<Provider store={store}>
<Suspense fallback={<GameLoader nodes={{}} />}>
<GamePage startMode={startMode} />
</Suspense>
</Provider>
);
}
+41
View File
@@ -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 (
<section id="loading" hidden={loading.hidden} aria-busy={!loading.hidden}>
<div className="flex w-64 flex-col items-center gap-4 text-center">
<img
src={ASSETS.brand.logo}
alt={strings.landing.logoAlt}
className="h-16 w-16 rounded-2xl"
/>
<p className="text-lg font-bold text-hud">{COMPANY.shortName}</p>
<div
role="progressbar"
aria-label={strings.app.buildingCity}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
className="h-2 w-full overflow-hidden rounded-full bg-white/10"
>
{/* Width is the live progress, so it is the one inline style here. */}
<div className="h-full rounded-full bg-primary" style={{ width: `${percent}%` }} />
</div>
<p className="text-sm">
{loading.text ?? strings.app.buildingCity}{' '}
<span dir="ltr">{strings.app.loadingPercent(percent.toLocaleString('fa-IR'))}</span>
</p>
</div>
</section>
);
}
+9 -3
View File
@@ -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 (
<main ref={root}>
<GameSurfaces {...props} />
<GameLoader {...props} />
<StartScreen {...props} />
<GameHud {...props} />
<GameDialogs {...props} />
+73
View File
@@ -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 (
<main className="min-h-dvh bg-surface text-hud">
<div className="relative">
<img
src={ASSETS.brand.hero}
alt={strings.poster.heroAlt}
className="h-[45dvh] w-full object-cover opacity-60"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-surface to-transparent p-6 pt-20">
<div className="mx-auto max-w-2xl">
<p className="text-2xl font-black md:text-4xl">{COMPANY.name}</p>
<p className="mt-2 text-primary">{COMPANY.tagline}</p>
</div>
</div>
</div>
<div className="mx-auto max-w-2xl p-6">
<p className="opacity-80">{strings.landing.intro}</p>
{canRender ? (
<button
type="button"
className="start-option start-option-primary mt-6"
onClick={onEnter}
>
{strings.poster.enter}
</button>
) : (
<FallbackInfo />
)}
</div>
</main>
);
}
/**
* Company information for browsers without 3D support.
* @returns {import('react').ReactElement} Notice and sections.
*/
function FallbackInfo() {
return (
<>
<p role="alert" className="mt-6 rounded-xl border border-primary/40 p-4">
{strings.poster.noWebgl}
</p>
{FALLBACK_SECTIONS.map((key) => {
const Body = SECTION_BODIES[key];
const title = key === 'about' ? strings.landing.history : strings.landing.sections[key];
return (
<section key={key} className="mt-8">
<h2 className="mb-3 text-lg font-bold">{title}</h2>
<Body />
</section>
);
})}
</>
);
}
+33
View File
@@ -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(<GamePoster canRender onEnter={onEnter} />);
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(<GamePoster canRender={false} onEnter={vi.fn()} />);
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(<GameLoader nodes={{ loading: { progress: 0.42 } }} />);
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '42');
});
});
-4
View File
@@ -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']}
></GameNode>
<GameNode as="canvas" id="world" model={nodes['world']} />
<GameNode as="div" id="loading" model={nodes['loading']}>
{strings.app.buildingCity}
</GameNode>
</>
);
}
+19 -13
View File
@@ -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 (
<Suspense fallback={<p role="status">{strings.app.preparing}</p>}>
<GamePage />
</Suspense>
);
return <CompanyGame {...optionsFromUrl()} />;
}
+78
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch],
);
/**
* Title and description tags.
* @returns {string} HTML for the head.
*/
export function seoHead() {
return [
`<title>${escapeHtml(strings.seo.title)}</title>`,
`<meta name="description" content="${escapeHtml(strings.seo.description)}" />`,
].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) => `<li><h3>${escapeHtml(p.name)}</h3><p>${escapeHtml(p.description)}</p></li>`)
.join('');
const address = strings.landing.fullAddress(contact.address, contact.city);
return `<section class="sr-only" aria-label="${escapeHtml(strings.seo.summaryLabel)}">
<h1>${escapeHtml(COMPANY.name)}</h1>
<p>${escapeHtml(COMPANY.tagline)}</p>
<p>${escapeHtml(about.history)}</p>
<h2>${escapeHtml(strings.seo.productsHeading)}</h2>
<ul>${items}</ul>
<h2>${escapeHtml(strings.landing.sections.contact)}</h2>
<p>${escapeHtml(address)}</p>
<p>${escapeHtml(`${strings.landing.phone}: ${contact.phone}`)}</p>
<p>${escapeHtml(`${strings.landing.email}: ${contact.email}`)}</p>
</section>`;
}
/**
* 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(/</g, '\\u003c');
return `<script type="application/ld+json">${json}</script>`;
}
+25
View File
@@ -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(`<title>${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);
});
+14 -1
View File
@@ -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: {