Various build shenenigans (#5)

This PR extends the build pipeline of the www service to support
locale-prefixed static builds (EN/RU) with build-time SSG output
injection, while also refactoring UI typography to a tokenized Tailwind
v4 @theme-driven approach.

Changes:

- Add build-time SSG plugin to @ermnvldmr/rsbuild-config and wire
multi-locale builds (/en/, /ru/) into services/www.
- Introduce new @ermnvldmr/i18n package (locale constants, path helpers,
keysets, pluralization) and apply it to www navigation/text.
- Refactor ui typography to a shared getTypographyClassNames utility +
Tailwind token definitions, with stl/cn updated for correct
Tailwind-merge behavior.
This commit is contained in:
deytenit authored and GitHub committed 2026-03-16 00:00:20 +03:00
1 parent ac9c455e5a
commit b84ef775b8
68 files changed
+2991 -397

No files matched your search

@@ -4,6 +4,7 @@ import { pluginTypeCheck } from '@rsbuild/plugin-type-check';
import { pluginDts } from 'rsbuild-plugin-dts';
import { mergeConfig } from '../utils/merge.js';
import { ssgPlugin } from './ssg.js';
/**
* Defines a standard Rsbuild configuration for services (apps).
@@ -21,7 +22,7 @@ import { mergeConfig } from '../utils/merge.js';
*/
export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
const defaultConfig: RsbuildConfig = {
plugins: [pluginReact(), pluginTypeCheck()],
plugins: [pluginReact(), pluginTypeCheck(), ssgPlugin()],
output: {
distPath: {
font: 'static/font',
@@ -35,28 +36,6 @@ export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
meta: {
description: 'Thoughts and creations of mine',
},
tags: [
{
tag: 'link',
attrs: {
rel: 'preload',
as: 'font',
type: 'font/woff2',
href: '/static/font/lato-400.woff2',
crossorigin: true,
},
},
{
tag: 'link',
attrs: {
rel: 'preload',
as: 'font',
type: 'font/woff2',
href: '/static/font/lato-700.woff2',
crossorigin: true,
},
},
],
},
};
+170 -4
View File
@@ -1,16 +1,182 @@
import fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import React from 'react';
import type { RsbuildPlugin } from '@rsbuild/core';
/**
* A minimal Rsbuild plugin for build-time Static Site Generation.
* Placeholder for future extension.
* Rsbuild plugin for build-time Static Site Generation.
*
* After the web build completes:
* 1. Builds each entry as a Node.js CJS bundle (dist-ssr/)
* 2. Renders each entry with react-dom/server renderToString
* 3. Injects the rendered HTML into the web build's HTML files
* 4. Runs critters to inline critical CSS and make the main stylesheet async
* 5. Cleans up dist-ssr/
*/
export function ssgPlugin(): RsbuildPlugin {
return {
name: 'ermnvldmr:ssg-plugin',
setup(api) {
api.onAfterBuild(async ({ stats }) => {
if (!stats) return;
console.log('[ssg-plugin] Build finished.');
if (!stats) {
console.warn('[ssg-plugin] No stats available, skipping SSG.');
return;
}
const config = api.getRsbuildConfig();
const rawEntries = config.source?.entry;
if (!rawEntries || typeof rawEntries !== 'object' || Array.isArray(rawEntries)) {
console.warn('[ssg-plugin] source.entry is not an object map, skipping SSG.');
return;
}
// Normalise entries: value may be a string path or an EntryDescription object
const entries: Record<string, string> = {};
for (const [name, value] of Object.entries(rawEntries)) {
if (typeof value === 'string') {
entries[name] = value;
} else if (value && typeof value === 'object' && 'import' in value) {
const imp = (value as { import: string | string[] }).import;
entries[name] = Array.isArray(imp) ? imp[0] : imp;
}
}
const entryNames = Object.keys(entries);
if (entryNames.length === 0) {
console.warn('[ssg-plugin] No entries to process, skipping SSG.');
return;
}
// api.context.distPath is the absolute path to the web build output directory.
// Fallback: derive from rootPath + output.distPath.root if the property is absent.
const webDistPath: string =
(api.context as { distPath?: string }).distPath ??
path.join(
api.context.rootPath,
(config.output?.distPath as { root?: string } | undefined)?.root ?? 'dist'
);
const ssrDistPath = path.join(path.dirname(webDistPath), 'dist-ssr');
// ── Step 1: Build node-target CJS bundles ─────────────────────────────
console.log('[ssg-plugin] Building SSR bundles...');
const { createRsbuild } = await import('@rsbuild/core');
// Reuse web build plugins minus ssgPlugin (avoids recursion) and pluginTypeCheck
// (type-checking is not needed for the SSR build). This automatically includes
// pluginReact, pluginMdx, and any other service-level plugins.
const SSG_PLUGIN_NAME = 'ermnvldmr:ssg-plugin';
const TYPE_CHECK_PLUGIN_NAME = 'rsbuild:type-check';
const ssrPlugins = (config.plugins ?? []).filter((p) => {
if (!p || typeof p !== 'object') return true;
const name = (p as { name?: string }).name;
return name !== SSG_PLUGIN_NAME && name !== TYPE_CHECK_PLUGIN_NAME;
});
const ssrBuild = await createRsbuild({
rsbuildConfig: {
plugins: ssrPlugins,
source: { entry: entries },
tools: config.tools,
output: {
target: 'node',
distPath: { root: ssrDistPath, js: '.' },
filename: { js: '[name].cjs' },
// Externalize React so the SSR bundle shares the same React instance
// as react-dom/server, preventing "null dispatcher" hook errors.
externals: {
react: 'commonjs react',
'react-dom': 'commonjs react-dom',
'react/jsx-runtime': 'commonjs react/jsx-runtime',
'react/jsx-dev-runtime': 'commonjs react/jsx-dev-runtime',
},
},
},
});
await ssrBuild.build();
console.log('[ssg-plugin] SSR bundles ready.');
// ── Step 2: Render, inject, inline critical CSS ───────────────────────
const { renderToString } = await import('react-dom/server');
// createRequire from ESM context — needed to load CJS modules (critters, SSR bundles)
const _require = createRequire(import.meta.url);
const Critters = _require('critters') as new (opts?: {
path?: string;
publicPath?: string;
preload?: string;
inlineFonts?: boolean;
pruneSource?: boolean;
}) => { process(html: string): Promise<string> };
// critters `path` is the filesystem root that corresponds to publicPath.
// It resolves <link href="..."> to webDistPath. Use configured assetPrefix for locale builds.
const configuredAssetPrefix =
(config.output as { assetPrefix?: string } | undefined)?.assetPrefix ?? '/';
const critters = new Critters({
path: webDistPath,
publicPath: configuredAssetPrefix,
preload: 'swap', // <link rel="preload" as="style" onload="this.rel='stylesheet'">
inlineFonts: false, // fonts are handled via separate <link rel="preload"> tags
pruneSource: false, // keep full CSS file for post-hydration correctness
});
for (const entryName of entryNames) {
const ssrBundlePath = path.join(ssrDistPath, `${entryName}.cjs`);
const webHtmlPath = path.join(webDistPath, `${entryName}.html`);
// Clear module cache so each entry loads fresh
const resolved = _require.resolve(ssrBundlePath);
delete _require.cache[resolved];
const mod = _require(ssrBundlePath) as { default?: React.ComponentType };
const Component = mod.default;
if (!Component) {
console.warn(`[ssg-plugin] Entry "${entryName}" has no default export — skipping.`);
continue;
}
// Render to HTML string
const renderedHtml = renderToString(
React.createElement(React.StrictMode, null, React.createElement(Component))
);
// Read web HTML, inject rendered content
const source = await fs.readFile(webHtmlPath, 'utf8');
const injected = source.replace(
/<div id="root"[^>]*>\s*<\/div>/,
`<div id="root">${renderedHtml}</div>`
);
if (injected === source) {
console.warn(
`[ssg-plugin] Could not find <div id="root"> in ${webHtmlPath} — injection skipped.`
);
}
// Run critters: inline critical CSS, make main stylesheet async
const processed = await critters.process(injected);
// Set <html lang> to the current build locale
const locale = process.env.LOCALE ?? 'en';
const localized = processed.replace(
/<html([^>]*)\slang="[^"]*"/,
`<html$1 lang="${locale}"`
);
await fs.writeFile(webHtmlPath, localized, 'utf8');
console.log(`[ssg-plugin] ✓ ${entryName}`);
}
// ── Step 3: Cleanup ───────────────────────────────────────────────────
await fs.rm(ssrDistPath, { recursive: true, force: true });
console.log('[ssg-plugin] SSG complete. dist-ssr cleaned up.');
});
},
};
+1
View File
@@ -1 +1,2 @@
export * from './config/index.js';
export { mergeConfig } from './utils/merge.js';