@wuchale/vite-plugin
Version:
Protobuf-like i18n from plain code: Vite plugin
200 lines (199 loc) • 8.86 kB
JavaScript
// $$ cd ../.. && npm run test
import { writeFile } from 'node:fs/promises';
import { dirname, relative, resolve } from 'node:path';
import { inspect } from 'node:util';
import { AdapterHandler, generatedDir, getConfig, Logger, normalizeSep, SharedStates, } from 'wuchale';
const pluginName = 'wuchale';
const confUpdateName = 'confUpdate.json';
export function toViteError(err, adapterKey, filename) {
const prefix = `${adapterKey}: transform failed for ${filename}`;
// Ensure we always throw an Error instance with a non-empty message so build tools (e.g. Vite)
// don't end up printing only a generic "error during build:" line.
if (err instanceof Error) {
const anyErr = err;
const frame = typeof anyErr.frame === 'string' ? anyErr.frame : undefined;
if (!err.message || !err.message.startsWith(prefix)) {
const details = err.message ? `\n${err.message}` : '';
const frameText = frame ? `\n\n${frame}` : '';
err.message = `${prefix}${details}${frameText}`;
}
// Preserve useful metadata that some tooling expects.
if (anyErr.id == null)
anyErr.id = filename;
if (anyErr.loc == null && anyErr.start?.line != null && anyErr.start?.column != null) {
anyErr.loc = { file: filename, line: anyErr.start.line, column: anyErr.start.column };
}
throw err;
}
const rendered = typeof err === 'string' ? err : inspect(err, { depth: 5, breakLength: 120, maxStringLength: 10_000 });
throw new Error(`${prefix}\n${rendered}`);
}
export class Wuchale {
name = pluginName;
#config;
#projectRoot = '';
#adapters = new Map();
#adaptersByConfUpdate = new Map();
#adaptersByCatalogPath = new Map();
#granularLoadAdapters = [];
#singleCompiledCatalogs = new Set();
#log;
#mode;
#loadConfig;
#hmrVersion = -1;
#hmrDelayThreshold;
#lastSourceTriggeredPOWrite = 0;
constructor(loadConfig, root, hmrDelayThreshold = 1000) {
this.#loadConfig = loadConfig;
this.#projectRoot = root;
// threshold to consider po file change is manual edit instead of a sideeffect of editing code
this.#hmrDelayThreshold = hmrDelayThreshold;
}
#init = async () => {
this.#config = await this.#loadConfig();
this.#log = new Logger(this.#config.logLevel);
const adaptersData = Object.entries(this.#config.adapters);
if (adaptersData.length === 0) {
throw Error('At least one adapter is needed.');
}
const sharedStates = new SharedStates();
const adaptersByLoaderPath = new Map();
for (const [key, adapter] of adaptersData) {
const handler = new AdapterHandler(adapter, key, this.#config, this.#mode, this.#projectRoot, this.#log);
await handler.init(sharedStates);
handler.onBeforeSave = () => {
this.#lastSourceTriggeredPOWrite = performance.now();
};
this.#adapters.set(key, handler);
if (adapter.granularLoad) {
this.#granularLoadAdapters.push(handler);
}
else {
for (const locale of this.#config.locales) {
this.#singleCompiledCatalogs.add(normalizeSep(resolve(handler.files.getCompiledFilePath(locale, null))));
}
}
for (const path of Object.values(handler.files.loaderPath)) {
const loaderPath = normalizeSep(resolve(path));
if (adaptersByLoaderPath.has(loaderPath)) {
const otherKey = adaptersByLoaderPath.get(loaderPath)?.key;
if (otherKey === key) {
// same loader for both ssr and client, no problem
continue;
}
throw new Error([
'While catalogs can be shared, the same loader cannot be used by multiple adapters',
`Conflicting: ${key} and ${otherKey}`,
'Specify a different loaderPath for one of them.',
].join('\n'));
}
adaptersByLoaderPath.set(loaderPath, handler);
}
for (const fname of handler.sharedState.storage.files) {
const normalized = normalizeSep(fname);
const handlers = this.#adaptersByCatalogPath.get(normalized);
if (handlers) {
handlers.push(handler);
}
else {
this.#adaptersByCatalogPath.set(normalized, [handler]);
}
}
const confUpdateFile = normalizeSep(resolve(this.#config.localesDir, generatedDir, confUpdateName));
await writeFile(confUpdateFile, '{}'); // vite only watches changes so prepare first
this.#adaptersByConfUpdate.set(confUpdateFile, handler);
}
};
configResolved = async (config) => {
if (config.env.DEV) {
this.#mode = 'dev';
}
else {
this.#mode = 'build';
}
await this.#init();
};
handleHotUpdate = async (ctx) => {
if (this.#adaptersByConfUpdate.has(ctx.file)) {
const update = JSON.parse(await ctx.read());
console.log(`${pluginName}: config update received:`, update);
this.#config.hmr = update.hmr;
return [];
}
if (!this.#config.hmr) {
return;
}
// This is mainly to make sure that PO catalog changes result in a page reload with new catalogs
const adapters = this.#adaptersByCatalogPath.get(ctx.file);
if (adapters == null) {
// prevent reloading whole app because of a change in compiled catalog
// triggered by extraction from single file, hmr handled by embedding patch
if (this.#singleCompiledCatalogs.has(ctx.file)) {
return [];
}
// for granular as well
for (const adapter of this.#granularLoadAdapters) {
for (const loc of this.#config.locales) {
for (const id of adapter.granularState.byID.keys()) {
if (normalizeSep(resolve(adapter.files.getCompiledFilePath(loc, id))) === ctx.file) {
return [];
}
}
}
}
this.#hmrVersion++;
return;
}
// catalog changed
const sourceTriggered = performance.now() - this.#lastSourceTriggeredPOWrite < this.#hmrDelayThreshold;
const invalidatedModules = new Set();
for (const adapter of adapters) {
for (const loc of adapter.allLocales) {
if (!sourceTriggered) {
await adapter.loadStorage();
await adapter.compile(this.#hmrVersion);
}
for (const loadID of adapter.getLoadIDs()[0]) {
const fileID = normalizeSep(resolve(adapter.files.getCompiledFilePath(loc, loadID)));
for (const module of ctx.server.moduleGraph.getModulesByFile(fileID) ?? []) {
ctx.server.moduleGraph.invalidateModule(module, invalidatedModules, ctx.timestamp, false);
}
}
}
}
if (!sourceTriggered) {
ctx.server.ws.send({ type: 'full-reload' });
return [];
}
};
#transformHandler = async (code, id, options) => {
if (this.#mode === 'dev' && !this.#config.hmr) {
return {};
}
let filename = relative(this.#projectRoot, id);
const queryIndex = filename.indexOf('?');
if (queryIndex >= 0) {
const query = new URLSearchParams(filename.slice(queryIndex));
if (query.size === 1 && query.has('v')) {
// trim after this, like ?v=b65b2c3b when it's from node_modules
filename = filename.slice(0, queryIndex);
}
}
for (const adapter of this.#adapters.values()) {
if (adapter.fileMatches(filename)) {
try {
const [output] = await adapter.transform(code, filename, this.#hmrVersion, options?.ssr);
return output;
}
catch (err) {
toViteError(err, adapter.key, filename);
}
}
}
return {};
};
transform = { order: 'pre', handler: this.#transformHandler };
}
export const wuchale = (configPath, hmrDelayThreshold = 1000) => {
return new Wuchale(() => getConfig(configPath), dirname(configPath ?? '.'), hmrDelayThreshold);
};