🤖 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:
deytenit authored and GitHub committed 2026-10-30 00:05:44 +03:00
1 parent 649d6c3fd0
commit 9200529bf0
16 files changed
+442 -368

No files matched your search

+5 -1
View File
@@ -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",
+79 -5
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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');
});
});
+121
View File
@@ -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);
}
}
-73
View File
@@ -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);
}
}
+9
View File
@@ -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/**'],
},
});