Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 7 additions & 18 deletions apps/_shared/vite-public-app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
*
* Each app loads as a <script src> from a Ghost theme and ships as a UMD
* (or IIFE) bundle in <app>/umd/<app>.min.js. This factory captures the
* shape they all share (outDir, lib, optional i18n locale glob, baseline
* plugins) and lets per-app divergence flow through `overrides`, which is
* deep-merged via Vite's `mergeConfig`. Plugin arrays in `overrides` are
* appended to the base plugins.
* shape they all share (outDir, lib, baseline plugins) and lets per-app
* divergence flow through `overrides`, which is deep-merged via Vite's
* `mergeConfig`. Plugin arrays in `overrides` are appended to the base plugins.
*
* Plugin imports are dynamic so an app can opt out (e.g. admin-toolbar
* uses Preact and never installs `@vitejs/plugin-react`).
*
* i18n note: apps import their locales via `@tryghost/i18n/registry/<namespace>`,
* a static ESM registry any bundler can resolve.
*/
import {resolve} from 'path';
import {defineConfig, mergeConfig} from 'vitest/config';
Expand All @@ -27,7 +29,6 @@ import {defineConfig, mergeConfig} from 'vitest/config';
* @param {string} [opts.libName] — global var name override (default: `packageName`)
* @param {boolean} [opts.sourcemap=true]
* @param {boolean} [opts.cssCodeSplit=true]
* @param {string} [opts.i18nNamespace] — if set, wires `commonjsOptions` to glob `ghost/i18n/locales/*\/<ns>.json`
* @param {import('vitest/config').UserConfig} [opts.overrides] — deep-merged onto the base config
* @returns {import('vitest/config').UserConfig}
*/
Expand All @@ -42,7 +43,6 @@ export function publicAppViteConfig(opts) {
libName,
sourcemap = true,
cssCodeSplit = true,
i18nNamespace,
overrides = {}
} = opts;

Expand Down Expand Up @@ -80,18 +80,7 @@ export function publicAppViteConfig(opts) {
formats: [libFormat],
name: libName ?? packageName,
fileName: () => `${outputFileName}.min.js`
},
...(i18nNamespace && {
commonjsOptions: {
include: [/ghost/, /node_modules/],
dynamicRequireRoot: '../../',
// Single glob expands to all SUPPORTED_LOCALES; passing each
// locale as an explicit path triggers a full repo-root
// directory crawl per entry under vite 7's bundled
// @rollup/plugin-commonjs, adding ~1s per locale to build time.
dynamicRequireTargets: [`../../ghost/i18n/locales/*/${i18nNamespace}.json`]
}
})
}
},
test: {
globals: true,
Expand Down
2 changes: 1 addition & 1 deletion apps/comments-ui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import AuthFrame from './auth-frame';
import ContentBox from './components/content-box';
import PopupBox from './components/popup-box';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import i18nLib from '@tryghost/i18n';
import i18nLib from '@tryghost/i18n/registry/comments';
import setupGhostApi from './utils/api';
import {ActionHandler, SyncActionHandler, isSyncAction} from './actions';
import {AppContext, Comment, DispatchActionType, EditableAppContext} from './app-context';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Pagination from '../../../../src/components/content/pagination';
import i18nLib from '@tryghost/i18n';
import i18nLib from '@tryghost/i18n/registry/comments';
import {AppContext} from '../../../../src/app-context';
import {render, screen} from '@testing-library/react';

Expand Down
1 change: 0 additions & 1 deletion apps/comments-ui/vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ export default publicAppViteConfig({
packageRoot: import.meta.dirname,
packageName: pkg.name,
entry: 'src/index.tsx',
i18nNamespace: 'comments',
sourcemap: false,
overrides: {
plugins: [stripFingerprintingPlugin()],
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/src/utils/i18n.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import i18nLib from '@tryghost/i18n';
import i18nLib from '@tryghost/i18n/registry/portal';

const i18n = i18nLib('en', 'portal');

Expand Down
2 changes: 1 addition & 1 deletion apps/portal/test/errors.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {HumanReadableError, chooseBestErrorMessage} from '../src/utils/errors';
import {vi} from 'vitest';

vi.mock('@tryghost/i18n', () => {
vi.mock('@tryghost/i18n/registry/portal', () => {
const mockT = vi.fn((message, params) => {
if (params?.number) {
return `translated ${message.replace('{number}', params.number)}`;
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/test/utils/test-utils.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Common test setup util - Ref: https://testing-library.com/docs/react-testing-library/setup#custom-render
import {render} from '@testing-library/react';
import i18n from '@tryghost/i18n';
import i18n from '@tryghost/i18n/registry/portal';
import AppContext from '../../src/app-context';
import {testSite, member} from '../../src/utils/fixtures';

Expand Down
1 change: 0 additions & 1 deletion apps/portal/vite.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ export default publicAppViteConfig({
packageRoot: import.meta.dirname,
packageName: pkg.name,
entry: 'src/index.jsx',
i18nNamespace: 'portal',
cssCodeSplit: false,
overrides: {
define: {
Expand Down
2 changes: 1 addition & 1 deletion apps/signup-form/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as i18nLibModule from '@tryghost/i18n';
import * as i18nLibModule from '@tryghost/i18n/registry/signup-form';
import React from 'react';

import type {Preview} from "@storybook/react-vite";
Expand Down
2 changes: 1 addition & 1 deletion apps/signup-form/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as i18nLibModule from '@tryghost/i18n';
import * as i18nLibModule from '@tryghost/i18n/registry/signup-form';
import React, {ComponentProps} from 'react';
import pages, {Page, PageName} from './pages';
import {AppContextProvider, AppContextType} from './app-context';
Expand Down
1 change: 1 addition & 0 deletions apps/signup-form/src/i18n.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
declare module '@tryghost/i18n';
declare module '@tryghost/i18n/registry/signup-form';
2 changes: 1 addition & 1 deletion apps/signup-form/src/preview.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as i18nLibModule from '@tryghost/i18n';
import * as i18nLibModule from '@tryghost/i18n/registry/signup-form';
import React, {useState} from 'react';
import pages, {Page, PageName} from './pages';
import {AppContextProvider, SignupFormOptions} from './app-context';
Expand Down
1 change: 0 additions & 1 deletion apps/signup-form/vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ export default publicAppViteConfig({
packageRoot: import.meta.dirname,
packageName: pkg.name,
entry: 'src/index.tsx',
i18nNamespace: 'signup-form',
sourcemap: false,
overrides: {
define: {
Expand Down
2 changes: 1 addition & 1 deletion apps/sodo-search/src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import AppContext from './app-context';
import PopupModal from './components/popup-modal';
import React from 'react';
import SearchIndex from './search-index.js';
import i18nLib from '@tryghost/i18n';
import i18nLib from '@tryghost/i18n/registry/search';

export default class App extends React.Component {
constructor(props) {
Expand Down
1 change: 0 additions & 1 deletion apps/sodo-search/vite.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ export default publicAppViteConfig({
packageRoot: import.meta.dirname,
packageName: pkg.name,
entry: 'src/index.jsx',
i18nNamespace: 'search',
sourcemap: false,
cssCodeSplit: false,
overrides: {
Expand Down
19 changes: 17 additions & 2 deletions ghost/i18n/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import {nodeLibConfig, noGhostIgnitionRequireRule} from '../../eslint.shared.mjs';

export default await nodeLibConfig({
export default [{
// Generated locale registries and ESM entry glue are code-generated / hand-authored
// ESM shims; they're not part of the linted CJS source surface.
ignores: ['lib/registry/**', 'build/**']
}, ...await nodeLibConfig({
// ghost/i18n is JS-only (CommonJS) and uses the local-filenames variant of
// match-regex instead of the ghost-plugin one. legacyLocalFilenames handles both.
typescript: false,
Expand All @@ -25,6 +29,17 @@ export default await nodeLibConfig({
// Keep the index entry points small — they're public surface.
files: ['lib/**/index.js', 'index.js'],
rules: {'max-lines': ['error', {skipBlankLines: true, skipComments: true, max: 50}]}
},
{
// Hand-authored ESM entry glue (lib/esm-factory.mjs). Lint it as real ESM so
// it isn't silently rule-less. The generated lib/registry/** stays ignored.
files: ['*.mjs', 'lib/**/*.mjs'],
languageOptions: {sourceType: 'module'},
rules: {
'no-unused-vars': 'error',
'no-undef': 'error'
}
}
]
});
})];

8 changes: 0 additions & 8 deletions ghost/i18n/index.browser.js

This file was deleted.

27 changes: 27 additions & 0 deletions ghost/i18n/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type {i18n as I18nextInstance} from 'i18next';

export interface LocaleDataEntry {
code: string;
name: string;
[key: string]: unknown;
}

export type Namespace = 'ghost' | 'portal' | 'signup-form' | 'comments' | 'search' | 'theme';

export interface I18nFactory {
(locale?: string, ns?: Namespace | string, options?: Record<string, unknown>): I18nextInstance;
LOCALE_DATA: LocaleDataEntry[];
SUPPORTED_LOCALES: string[];
generateResources(locales: string[], ns: string): Record<string, Record<string, Record<string, string>>>;
default: I18nFactory;
}

declare const i18n: I18nFactory;

export default i18n;
export declare const LOCALE_DATA: LocaleDataEntry[];
export declare const SUPPORTED_LOCALES: string[];
export declare function generateResources(
locales: string[],
ns: string
): Record<string, Record<string, Record<string, string>>>;
46 changes: 46 additions & 0 deletions ghost/i18n/lib/esm-factory.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Shared ESM factory for the browser/static-registry build.
*
* Given a single-namespace registry (locale-code -> resource JSON), returns an
* i18n factory with the SAME call signature the CJS package exposes:
* i18n(locale, ns, options) -> initialised i18next instance
*
* Theme resources are stubbed to `{}` — themes are a Node-only, fs-backed concept
* and never reach the browser (matches the old index.browser.js).
*
* The namespace is fixed at build time by which per-namespace registry module is
* imported, so bundlers include ONLY that namespace's locale files.
*
* Note: this imports the ESM core (./i18n-core.mjs), NOT the CJS ./i18n-core.js.
* The CJS core uses require('i18next'), which leaks a bare `require(...)` into the
* browser UMD bundle and throws "require is not defined" at load.
*/
import * as i18nCore from './i18n-core.mjs';

const {createI18n, createGenerateResources, LOCALE_DATA, SUPPORTED_LOCALES} = i18nCore;

export function createNamespacedI18n(registry, boundNamespace) {
// Registry loader: returns undefined for an unknown locale so the core falls
// back to English (mirrors the CJS try/catch fallback).
const generateResources = createGenerateResources(locale => registry[locale]);

function generateThemeResources(lng) {
return {
[lng]: {
theme: {}
}
};
}

const i18n = createI18n({generateResources, generateThemeResources});

i18n.LOCALE_DATA = LOCALE_DATA;
i18n.SUPPORTED_LOCALES = SUPPORTED_LOCALES;
i18n.generateResources = generateResources;
i18n.namespace = boundNamespace;
i18n.default = i18n;

return i18n;
}

export {LOCALE_DATA, SUPPORTED_LOCALES};
58 changes: 38 additions & 20 deletions ghost/i18n/lib/i18n-core.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
// CJS (Node) core for @tryghost/i18n.
//
// Sibling of ./i18n-core.mjs (the ESM/browser twin). Ghost core require()s this
// package synchronously, so the Node path must stay CommonJS; the browser path
// must be pure ESM (a CJS file in the browser bundle leaks a bare `require(...)`
// or `module.exports` and throws at load). The two cores are therefore kept as
// deliberate twins and guarded by a parity test in test/i18n.test.js — change
// shared behaviour in BOTH files.
const i18next = require('i18next');

// Locale data loaded from JSON (single source of truth)
Expand All @@ -6,27 +14,36 @@ const LOCALE_DATA = require('./locale-data.json');
// Export just the locale codes for backward compatibility
const SUPPORTED_LOCALES = LOCALE_DATA.map(locale => locale.code);

function generateResources(locales, ns) {
return locales.reduce((acc, locale) => {
let res;
// add an extra fallback - this handles the case where we have a partial set of translations for some reason
// by falling back to the english translations
try {
res = require(`../locales/${locale}/${ns}.json`);
} catch (err) {
res = require(`../locales/en/${ns}.json`);
}
// Merge quirk preserved verbatim from the original implementation:
// Note: due some random thing in TypeScript, 'requiring' a JSON file with a space in a key name, only adds it to the default export
// If changing this behaviour, please also check the comments and signup-form apps in another language (mainly sentences with a space in them)
function mergeDefaultExport(res) {
return {...res, ...(res.default && typeof res.default === 'object' ? res.default : {})};
}

// Note: due some random thing in TypeScript, 'requiring' a JSON file with a space in a key name, only adds it to the default export
// If changing this behaviour, please also check the comments and signup-form apps in another language (mainly sentences with a space in them)
acc[locale] = {
[ns]: {...res, ...(res.default && typeof res.default === 'object' ? res.default : {})}
};
return acc;
}, {});
// Factory: given a resource loader, produce a `generateResources(locales, ns)` fn
// with the exact original behaviour/shape. Callers that need the classic dynamic-require
// behaviour just use the default-exported `generateResources` below.
function createGenerateResources(loadResource) {
return function generateResources(locales, ns) {
return locales.reduce((acc, locale) => {
let res = loadResource(locale, ns);
// Fallback to English if a locale/namespace pair is missing entirely.
if (res === undefined) {
res = loadResource('en', ns);
}
// English floor: if even English is missing for this namespace, use an
// empty object so the promised English fallback never lets
// mergeDefaultExport receive undefined (which would throw).
acc[locale] = {
[ns]: mergeDefaultExport(res || {})
};
return acc;
}, {});
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function createI18n({generateThemeResources}) {
function createI18n({generateThemeResources, generateResources: genResources}) {
return (lng = 'en', ns = 'portal', options = {}) => {
const i18nextInstance = i18next.createInstance();
const interpolation = {
Expand All @@ -38,7 +55,7 @@ function createI18n({generateThemeResources}) {
}
let resources;
if (ns !== 'theme') {
resources = generateResources(SUPPORTED_LOCALES, ns);
resources = genResources(SUPPORTED_LOCALES, ns);
} else {
resources = generateThemeResources(lng, options);
}
Expand Down Expand Up @@ -74,7 +91,8 @@ function createI18n({generateThemeResources}) {

module.exports = {
createI18n,
generateResources,
createGenerateResources,
mergeDefaultExport,
LOCALE_DATA,
SUPPORTED_LOCALES
};
Loading
Loading