## Summary - **Cloudflare R2 Migration**: - Migrated `static.ermnvldmr.com` asset storage to Cloudflare R2 bucket `com-ermnvldmr-static` (location `WEUR`). - Added support for `COM_ERMNVLDMR_STATIC_PUBLIC_URL` fallback in `@ermnvldmr/cli` to properly format public asset URLs for non-dotted bucket names. - Relocated public CA cert to `/dev/ssl/certs/ErmnvldmrDevelopmentRootCA.pem` and updated `REMOTE_ROOT_CA_URL` references in `@ermnvldmr/rsbuild-config` and `ensure-dev-certs.mjs`. - Configured R2 CORS policies using wildcards (`https://*.ermnvldmr.com`, `https://ermnvldmr.com`, `https://localhost.ermnvldmr.com*`, `http://localhost*`, `https://direct.yandex.ru`, `https://*.direct.yandex.ru`, `https://localhost.yandex.ru*`). - Enforced minimum TLS 1.2 on zone `ermnvldmr.com`. - Updated encrypted `.env.dev` and GitHub secrets in both `deytenit/ermnvldmr.com` and `deytenit/apex.ermnvldmr.com`. - **Multi-locale SSG Support**: - Added `NormalModuleReplacementPlugin` to SSR runner in `@ermnvldmr/ssg` for automatic `.en.mdx` -> `.${locale}.mdx` substitution during Russian builds. - Added page metadata extraction (`getPageOptions`) in `@ermnvldmr/ssg` for pre-rendered `<title>` and `<meta name="description">` tags. - Added unit test suite for `createPage` and updated `services/www` build scripts (`build:all`, `build:en`, `build:ru`, `dev:en`, `dev:ru`).
122 lines
3.4 KiB
TypeScript
122 lines
3.4 KiB
TypeScript
import React from 'react';
|
|
|
|
/**
|
|
* Options for page creation.
|
|
*/
|
|
export interface PageOptions {
|
|
/** Document title to set on mount. */
|
|
title?: string;
|
|
/** Content of the meta description tag. */
|
|
description?: string;
|
|
}
|
|
|
|
/**
|
|
* Internal interface representing a component with attached page options.
|
|
*/
|
|
interface HasPageOptions {
|
|
/** The attached page options. */
|
|
__pageOptions?: PageOptions;
|
|
}
|
|
|
|
/**
|
|
* Type predicate checking if a value is an object or function with attached page options.
|
|
*
|
|
* @param value - The value to inspect.
|
|
* @returns True if value has __pageOptions.
|
|
* @example
|
|
* ```ts
|
|
* if (hasPageOptions(Component)) {
|
|
* console.log(Component.__pageOptions);
|
|
* }
|
|
* ```
|
|
*/
|
|
function hasPageOptions(value: unknown): value is HasPageOptions {
|
|
return (
|
|
(typeof value === 'function' || (typeof value === 'object' && value !== null)) &&
|
|
'__pageOptions' in value
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Retrieves the page metadata options attached to a component via createPage.
|
|
*
|
|
* @param component - The component to inspect.
|
|
* @returns The associated PageOptions, or undefined if none are set.
|
|
* @example
|
|
* ```ts
|
|
* const options = getPageOptions(MyPageComponent);
|
|
* ```
|
|
*/
|
|
export function getPageOptions(component: unknown): PageOptions | undefined {
|
|
if (hasPageOptions(component)) {
|
|
return component.__pageOptions;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Mounts or hydrates a React component into the `#root` element.
|
|
*
|
|
* Attaches metadata options for SSG extraction. Skips DOM mounting during the SSG
|
|
* server-side render pass (`typeof window === 'undefined'`). On the client, hydrates
|
|
* asynchronously during idle time to unblock First Contentful Paint.
|
|
*
|
|
* @param Component - The React component to render.
|
|
* @param options - Optional page metadata (title, description).
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* createPage(App, { title: 'My Page' });
|
|
* ```
|
|
*/
|
|
export function createPage(Component: React.ComponentType, options: PageOptions = {}): void {
|
|
Object.assign(Component, { __pageOptions: options });
|
|
|
|
if (typeof window === 'undefined') return; // SSG build context: skip DOM mounting
|
|
const rootElement = document.getElementById('root');
|
|
|
|
if (options.title && document.title !== options.title) {
|
|
document.title = options.title;
|
|
}
|
|
|
|
if (options.description) {
|
|
let metaDescription = document.querySelector('meta[name="description"]');
|
|
if (!metaDescription) {
|
|
metaDescription = document.createElement('meta');
|
|
metaDescription.setAttribute('name', 'description');
|
|
document.head.appendChild(metaDescription);
|
|
}
|
|
if (metaDescription.getAttribute('content') !== options.description) {
|
|
metaDescription.setAttribute('content', options.description);
|
|
}
|
|
}
|
|
|
|
if (!rootElement) return;
|
|
|
|
const mountOrHydrate = () => {
|
|
void import('react-dom/client')
|
|
.then(({ createRoot, hydrateRoot }) => {
|
|
const node = React.createElement(
|
|
React.StrictMode,
|
|
null,
|
|
React.createElement(Component)
|
|
);
|
|
|
|
if (rootElement.hasChildNodes()) {
|
|
hydrateRoot(rootElement, node);
|
|
} else {
|
|
createRoot(rootElement).render(node);
|
|
}
|
|
})
|
|
.catch((err: unknown) => {
|
|
console.error('[createPage] Client hydration failed:', err);
|
|
});
|
|
};
|
|
|
|
if ('requestIdleCallback' in window) {
|
|
window.requestIdleCallback(mountOrHydrate);
|
|
} else {
|
|
setTimeout(mountOrHydrate, 1);
|
|
}
|
|
}
|