🤖 feat: migrate static S3 storage to Cloudflare R2 and add multi-locale SSG support (#70)
## 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`).
This commit is contained in:
1 parent
649d6c3fd0
commit
9200529bf0
16 files changed
+442
-368
No files matched your search
@@ -24,6 +24,7 @@
|
||||
"build": "tsc",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -39,6 +40,8 @@
|
||||
"@rsbuild/core": "^1.7.3",
|
||||
"@rsbuild/plugin-react": "^1.1.1",
|
||||
"@rsbuild/plugin-type-check": "^1.1.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/node": "*",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
@@ -46,7 +49,8 @@
|
||||
"fast-glob": "^3.3.3",
|
||||
"jsdom": "^28.0.0",
|
||||
"rsbuild-plugin-dts": "^0.19.5",
|
||||
"typescript": "^5.8.2"
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -5,14 +5,36 @@ import path from 'node:path';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { getPageOptions } from '../core/createPage.js';
|
||||
|
||||
import type { RsbuildPlugin } from '@rsbuild/core';
|
||||
|
||||
/**
|
||||
* Escapes HTML special characters for safe injection into markup.
|
||||
*
|
||||
* @param str - The raw string to escape.
|
||||
* @returns The escaped HTML string.
|
||||
* @example
|
||||
* ```ts
|
||||
* escapeHtml('<script>alert("xss")</script>');
|
||||
* ```
|
||||
*/
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const SSR_RUNNER_SCRIPT = `
|
||||
import { createRsbuild } from '@rsbuild/core';
|
||||
|
||||
async function main() {
|
||||
const entries = JSON.parse(process.argv[1]);
|
||||
const ssrDistPath = process.argv[2];
|
||||
const locale = process.argv[3] || process.env.LOCALE || 'en';
|
||||
|
||||
const plugins = [];
|
||||
try {
|
||||
@@ -31,10 +53,35 @@ async function main() {
|
||||
// MDX plugins not installed or needed in this service
|
||||
}
|
||||
|
||||
if (locale !== 'en') {
|
||||
plugins.push({
|
||||
name: 'ermnvldmr:ssr-locale-mdx-replacement',
|
||||
setup(api) {
|
||||
api.modifyRspackConfig((config, { rspack }) => {
|
||||
config.plugins = [
|
||||
...(config.plugins ?? []),
|
||||
new rspack.NormalModuleReplacementPlugin(
|
||||
/\\.en\\.mdx$/,
|
||||
(resource) => {
|
||||
resource.request = resource.request.replace(/\\.en\\.mdx$/, '.' + locale + '.mdx');
|
||||
}
|
||||
),
|
||||
];
|
||||
return config;
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const ssrBuild = await createRsbuild({
|
||||
rsbuildConfig: {
|
||||
plugins,
|
||||
source: { entry: entries },
|
||||
source: {
|
||||
entry: entries,
|
||||
define: {
|
||||
'process.env.LOCALE': JSON.stringify(locale),
|
||||
},
|
||||
},
|
||||
output: {
|
||||
target: 'node',
|
||||
distPath: { root: ssrDistPath, js: '.' },
|
||||
@@ -119,11 +166,16 @@ export function ssgPlugin(): RsbuildPlugin {
|
||||
);
|
||||
const ssrDistPath = path.join(path.dirname(webDistPath), 'dist-ssr');
|
||||
|
||||
const locale = process.env.LOCALE ?? 'en';
|
||||
|
||||
console.log('[ssg-plugin] Building SSR bundles...');
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
['--input-type=module', '-e', SSR_RUNNER_SCRIPT, JSON.stringify(entries), ssrDistPath],
|
||||
{ stdio: 'inherit' }
|
||||
['--input-type=module', '-e', SSR_RUNNER_SCRIPT, JSON.stringify(entries), ssrDistPath, locale],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, LOCALE: locale },
|
||||
}
|
||||
);
|
||||
console.log('[ssg-plugin] SSR bundles ready.');
|
||||
|
||||
@@ -175,8 +227,10 @@ export function ssgPlugin(): RsbuildPlugin {
|
||||
React.createElement(React.StrictMode, null, React.createElement(Component))
|
||||
);
|
||||
|
||||
const pageOptions = getPageOptions(Component);
|
||||
|
||||
const source = await fs.readFile(webHtmlPath, 'utf8');
|
||||
const injected = source.replace(
|
||||
let injected = source.replace(
|
||||
/<div id="root"[^>]*>\s*<\/div>/,
|
||||
`<div id="root">${renderedHtml}</div>`
|
||||
);
|
||||
@@ -187,9 +241,29 @@ export function ssgPlugin(): RsbuildPlugin {
|
||||
);
|
||||
}
|
||||
|
||||
if (pageOptions?.title) {
|
||||
injected = injected.replace(
|
||||
/<title>[^<]*<\/title>/i,
|
||||
`<title>${escapeHtml(pageOptions.title)}</title>`
|
||||
);
|
||||
}
|
||||
|
||||
if (pageOptions?.description) {
|
||||
if (/<meta\s+[^>]*name=["']description["'][^>]*>/i.test(injected)) {
|
||||
injected = injected.replace(
|
||||
/<meta\s+[^>]*name=["']description["'][^>]*>/i,
|
||||
`<meta name="description" content="${escapeHtml(pageOptions.description)}">`
|
||||
);
|
||||
} else {
|
||||
injected = injected.replace(
|
||||
/<\/head>/i,
|
||||
` <meta name="description" content="${escapeHtml(pageOptions.description)}">\n</head>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const processed = await critters.process(injected);
|
||||
|
||||
const locale = process.env.LOCALE ?? 'en';
|
||||
const localized = processed.replace(
|
||||
/<html([^>]*)\slang="[^"]*"/,
|
||||
`<html$1 lang="${locale}"`
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createPage, getPageOptions } from './createPage';
|
||||
|
||||
describe('createPage and getPageOptions', () => {
|
||||
beforeEach(() => {
|
||||
document.title = 'Default Title';
|
||||
const existingMeta = document.querySelector('meta[name="description"]');
|
||||
if (existingMeta) {
|
||||
existingMeta.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('attaches metadata options to component and retrieves them via getPageOptions', () => {
|
||||
function SamplePage(): React.JSX.Element {
|
||||
return <div>Sample</div>;
|
||||
}
|
||||
|
||||
createPage(SamplePage, {
|
||||
title: 'Sample Title',
|
||||
description: 'Sample Description',
|
||||
});
|
||||
|
||||
const options = getPageOptions(SamplePage);
|
||||
expect(options).toEqual({
|
||||
title: 'Sample Title',
|
||||
description: 'Sample Description',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for entities without metadata options', () => {
|
||||
function PlainComponent(): React.JSX.Element {
|
||||
return <div>Plain</div>;
|
||||
}
|
||||
|
||||
expect(getPageOptions(PlainComponent)).toBeUndefined();
|
||||
expect(getPageOptions(null)).toBeUndefined();
|
||||
expect(getPageOptions(undefined)).toBeUndefined();
|
||||
expect(getPageOptions('string')).toBeUndefined();
|
||||
expect(getPageOptions(123)).toBeUndefined();
|
||||
expect(getPageOptions({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates document.title and creates meta description in DOM', () => {
|
||||
function MetaPage(): React.JSX.Element {
|
||||
return <div>Meta</div>;
|
||||
}
|
||||
|
||||
createPage(MetaPage, {
|
||||
title: 'Custom Title',
|
||||
description: 'Custom Description',
|
||||
});
|
||||
|
||||
expect(document.title).toBe('Custom Title');
|
||||
const meta = document.querySelector('meta[name="description"]');
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta?.getAttribute('content')).toBe('Custom Description');
|
||||
});
|
||||
|
||||
it('updates existing meta description if already present in DOM', () => {
|
||||
const existingMeta = document.createElement('meta');
|
||||
existingMeta.setAttribute('name', 'description');
|
||||
existingMeta.setAttribute('content', 'Old Description');
|
||||
document.head.appendChild(existingMeta);
|
||||
|
||||
function AnotherMetaPage(): React.JSX.Element {
|
||||
return <div>Another Meta</div>;
|
||||
}
|
||||
|
||||
createPage(AnotherMetaPage, {
|
||||
description: 'Updated Description',
|
||||
});
|
||||
|
||||
const meta = document.querySelector('meta[name="description"]');
|
||||
expect(meta?.getAttribute('content')).toBe('Updated Description');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts or hydrates a React component into the `#root` element.
|
||||
*
|
||||
* 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 {
|
||||
if (typeof window === 'undefined') return; // SSG build context: skip DOM mounting
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
if (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);
|
||||
}
|
||||
metaDescription.setAttribute('content', options.description);
|
||||
}
|
||||
|
||||
if (!rootElement) return;
|
||||
|
||||
const mountOrHydrate = () => {
|
||||
void import('react-dom/client')
|
||||
.then(({ createRoot, hydrateRoot }) => {
|
||||
const node = (
|
||||
<React.StrictMode>
|
||||
<Component />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { baseVitestConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
import { mergeConfig } from 'vitest/config';
|
||||
|
||||
export default mergeConfig(baseVitestConfig, {
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user