@byline/core 2.5.2 → 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/@types/admin-types.d.ts +5 -2
- package/dist/@types/site-config.d.ts +44 -0
- package/dist/core.js +21 -0
- package/dist/services/i18n-validator.d.ts +48 -0
- package/dist/services/i18n-validator.js +99 -0
- package/dist/services/i18n-validator.test.node.d.ts +8 -0
- package/dist/services/i18n-validator.test.node.js +133 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/richtext-embed.test.node.d.ts +8 -0
- package/dist/services/richtext-embed.test.node.js +149 -0
- package/dist/validation/shared.d.ts +20 -0
- package/dist/validation/shared.js +23 -3
- package/package.json +2 -2
|
@@ -216,8 +216,11 @@ export interface CollectionAdminConfig<T = any> {
|
|
|
216
216
|
/**
|
|
217
217
|
* Preview URL configuration for the admin's live-preview affordance
|
|
218
218
|
* (`<PreviewLink>` icon on the document edit page header). When omitted,
|
|
219
|
-
* the preview link
|
|
220
|
-
*
|
|
219
|
+
* the preview link falls back through `CollectionDefinition.buildDocumentPath`
|
|
220
|
+
* (the same schema-side hook the richtext embed walker reads, so the
|
|
221
|
+
* public path and the Preview button agree by construction) and finally
|
|
222
|
+
* to the conventional `/${collectionPath}/${doc.path}` — fine for
|
|
223
|
+
* collections whose public URL mirrors the collection path.
|
|
221
224
|
*
|
|
222
225
|
* `url(doc, ctx)` — pure function returning the preview URL. Receives
|
|
223
226
|
* the loaded document and a small request-scoped context object
|
|
@@ -36,11 +36,42 @@ export interface BaseConfig {
|
|
|
36
36
|
interface: {
|
|
37
37
|
defaultLocale: string;
|
|
38
38
|
locales: string[];
|
|
39
|
+
/**
|
|
40
|
+
* Optional display names for the admin language switcher. Each
|
|
41
|
+
* entry pairs a permitted locale code with the label users see
|
|
42
|
+
* in the menu — `Français` rather than the lowercase `français`
|
|
43
|
+
* that CLDR's `Intl.DisplayNames` returns for romance languages.
|
|
44
|
+
*
|
|
45
|
+
* Hosts that omit this fall back to `Intl.DisplayNames` per
|
|
46
|
+
* code; hosts that provide it for some codes still fall back
|
|
47
|
+
* for the rest. Entries for codes outside `locales` are silently
|
|
48
|
+
* ignored.
|
|
49
|
+
*/
|
|
50
|
+
localeDefinitions?: ReadonlyArray<{
|
|
51
|
+
code: string;
|
|
52
|
+
nativeName: string;
|
|
53
|
+
}>;
|
|
39
54
|
};
|
|
40
55
|
content: {
|
|
41
56
|
defaultLocale: string;
|
|
42
57
|
locales: string[];
|
|
43
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Admin interface translation registry — a `TranslationBundle`
|
|
61
|
+
* produced by `@byline/i18n`'s `mergeTranslations(...)`. Locale →
|
|
62
|
+
* namespace → key → ICU MessageFormat-encoded string.
|
|
63
|
+
*
|
|
64
|
+
* Optional at the type level so `BaseConfig` stays loose for tests
|
|
65
|
+
* and seed scripts; required at runtime via `validateTranslations`
|
|
66
|
+
* whenever `interface.locales` is non-empty. See `docs/I18N.md` for
|
|
67
|
+
* the design.
|
|
68
|
+
*
|
|
69
|
+
* The shape is declared inline (rather than imported from
|
|
70
|
+
* `@byline/i18n`) so `@byline/core` stays a leaf-ish package. The
|
|
71
|
+
* `TranslationBundle` type from `@byline/i18n` is structurally
|
|
72
|
+
* assignable to this slot.
|
|
73
|
+
*/
|
|
74
|
+
translations?: TranslationBundleShape;
|
|
44
75
|
};
|
|
45
76
|
collections: CollectionDefinition[];
|
|
46
77
|
/**
|
|
@@ -51,6 +82,19 @@ export interface BaseConfig {
|
|
|
51
82
|
*/
|
|
52
83
|
routes?: Partial<RoutesConfig>;
|
|
53
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Inline structural shape for the admin translation registry. Kept here
|
|
87
|
+
* (rather than imported from `@byline/i18n`) so `@byline/core` doesn't
|
|
88
|
+
* take a runtime dep on the i18n package. `@byline/i18n`'s
|
|
89
|
+
* `TranslationBundle` is structurally identical and assignable.
|
|
90
|
+
*/
|
|
91
|
+
export type TranslationBundleShape = Readonly<{
|
|
92
|
+
[locale: string]: Readonly<{
|
|
93
|
+
[namespace: string]: Readonly<{
|
|
94
|
+
[key: string]: string;
|
|
95
|
+
}>;
|
|
96
|
+
}>;
|
|
97
|
+
}>;
|
|
54
98
|
/**
|
|
55
99
|
* Client-side configuration. Extends BaseConfig with admin UI presentation
|
|
56
100
|
* config (React components, formatters, column definitions, etc.).
|
package/dist/core.js
CHANGED
|
@@ -12,6 +12,7 @@ import { createBylineLogger, defineLogger } from './lib/logger.js';
|
|
|
12
12
|
import { Registry } from './lib/registry.js';
|
|
13
13
|
import { ensureCollections } from './services/collection-bootstrap.js';
|
|
14
14
|
import { discoverCounterGroups } from './services/discover-counter-groups.js';
|
|
15
|
+
import { validateTranslations } from './services/i18n-validator.js';
|
|
15
16
|
import { validateRichTextFieldFlags } from './services/richtext-populate.js';
|
|
16
17
|
/**
|
|
17
18
|
* Initialize Byline CMS core services via the typed registry.
|
|
@@ -45,6 +46,26 @@ export const initBylineCore = async (config, pinoLogger) => {
|
|
|
45
46
|
populate: config.fields?.richText?.populate != null,
|
|
46
47
|
embed: config.fields?.richText?.embed != null,
|
|
47
48
|
});
|
|
49
|
+
// Validate the admin i18n translation registry against the configured
|
|
50
|
+
// interface locale set. Throws on structural errors (missing bundle for
|
|
51
|
+
// a declared locale, defaultLocale outside the permitted set, …); soft
|
|
52
|
+
// warnings (key-set drift between locales) are logged so contributors
|
|
53
|
+
// see translation gaps without it blocking boot. The bundle lives at
|
|
54
|
+
// `i18n.translations` (one axis, not nested under `interface`) — the
|
|
55
|
+
// validator takes the assembled shape so locale config and bundle
|
|
56
|
+
// travel together.
|
|
57
|
+
const i18nValidation = validateTranslations({
|
|
58
|
+
defaultLocale: config.i18n.interface.defaultLocale,
|
|
59
|
+
locales: config.i18n.interface.locales,
|
|
60
|
+
translations: config.i18n.translations,
|
|
61
|
+
});
|
|
62
|
+
for (const warning of i18nValidation.warnings) {
|
|
63
|
+
composed.logger.warn({
|
|
64
|
+
locale: warning.locale,
|
|
65
|
+
namespace: warning.namespace,
|
|
66
|
+
missingKeys: warning.missingKeys,
|
|
67
|
+
}, `[i18n] '${warning.locale}.${warning.namespace}' is missing ${warning.missingKeys.length} key(s) relative to the other locales`);
|
|
68
|
+
}
|
|
48
69
|
// Backward compat: populate globalThis singletons
|
|
49
70
|
defineServerConfig(config);
|
|
50
71
|
defineLogger(composed.logger);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Boot-time validator for the admin translation registry. Mirrors the
|
|
10
|
+
* fail-fast posture of `validateRichTextFieldFlags` — surfacing wiring
|
|
11
|
+
* mistakes at `initBylineCore()` rather than at first request.
|
|
12
|
+
*
|
|
13
|
+
* Rules enforced:
|
|
14
|
+
* 1. `defaultLocale` must be in the permitted `locales` set.
|
|
15
|
+
* 2. When `locales` is non-empty, `translations` must be present.
|
|
16
|
+
* 3. Every locale in `locales` must have at least one namespace +
|
|
17
|
+
* one key in `translations`. A locale with zero translations is
|
|
18
|
+
* almost always a missing-bundle bug — at minimum the consumer
|
|
19
|
+
* should opt out of the locale set entirely.
|
|
20
|
+
*
|
|
21
|
+
* Soft warning (returned, not thrown):
|
|
22
|
+
* - Key set drift across locales. A namespace that carries `{ a, b }`
|
|
23
|
+
* in `en` and `{ a }` in `fr` reports `fr` as missing `b`. Surfaces
|
|
24
|
+
* translation gaps without blocking boot — partial translations are
|
|
25
|
+
* normal during community-contributor flow.
|
|
26
|
+
*/
|
|
27
|
+
import type { TranslationBundleShape } from '../@types/site-config.js';
|
|
28
|
+
export interface InterfaceI18nConfig {
|
|
29
|
+
defaultLocale: string;
|
|
30
|
+
locales: string[];
|
|
31
|
+
translations?: TranslationBundleShape;
|
|
32
|
+
}
|
|
33
|
+
export interface TranslationDriftWarning {
|
|
34
|
+
locale: string;
|
|
35
|
+
namespace: string;
|
|
36
|
+
/** Keys present in some other locale's same namespace but absent here. */
|
|
37
|
+
missingKeys: string[];
|
|
38
|
+
}
|
|
39
|
+
export interface ValidateTranslationsResult {
|
|
40
|
+
/** Soft warnings — caller decides whether to log or ignore. */
|
|
41
|
+
warnings: TranslationDriftWarning[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Validate the admin translation slot against the configured interface
|
|
45
|
+
* locale set. Throws on any structural error; returns soft warnings for
|
|
46
|
+
* key-set drift between locales.
|
|
47
|
+
*/
|
|
48
|
+
export declare function validateTranslations(i18n: InterfaceI18nConfig): ValidateTranslationsResult;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Validate the admin translation slot against the configured interface
|
|
10
|
+
* locale set. Throws on any structural error; returns soft warnings for
|
|
11
|
+
* key-set drift between locales.
|
|
12
|
+
*/
|
|
13
|
+
export function validateTranslations(i18n) {
|
|
14
|
+
const { defaultLocale, locales, translations } = i18n;
|
|
15
|
+
const errors = [];
|
|
16
|
+
// (1) Default must be in the permitted set.
|
|
17
|
+
if (locales.length > 0 && !locales.includes(defaultLocale)) {
|
|
18
|
+
errors.push(`defaultLocale '${defaultLocale}' is not in i18n.interface.locales [${locales.join(', ')}]. ` +
|
|
19
|
+
`Add it to locales, or change defaultLocale to one of the existing entries.`);
|
|
20
|
+
}
|
|
21
|
+
// No locales declared → skip the remaining checks. Hosts that don't
|
|
22
|
+
// mount the admin UI (seeds, migrations, headless tooling) can omit
|
|
23
|
+
// translations entirely.
|
|
24
|
+
if (locales.length === 0) {
|
|
25
|
+
return { warnings: [] };
|
|
26
|
+
}
|
|
27
|
+
// (2) Non-empty locale set requires a translations bundle.
|
|
28
|
+
if (translations == null) {
|
|
29
|
+
errors.push(`i18n.interface.locales declares [${locales.join(', ')}] but no translations bundle is registered. ` +
|
|
30
|
+
`Pass one to defineClientConfig via i18n.translations — see @byline/i18n's adminTranslations() ` +
|
|
31
|
+
`and mergeTranslations() helpers.`);
|
|
32
|
+
if (errors.length > 0) {
|
|
33
|
+
throw new Error(formatErrors(errors));
|
|
34
|
+
}
|
|
35
|
+
return { warnings: [] };
|
|
36
|
+
}
|
|
37
|
+
// (3) Every declared locale must have at least one (namespace, key).
|
|
38
|
+
for (const locale of locales) {
|
|
39
|
+
const localeBundle = translations[locale];
|
|
40
|
+
if (localeBundle == null || namespaceKeyCount(localeBundle) === 0) {
|
|
41
|
+
errors.push(`i18n.interface.locales includes '${locale}' but no translations are registered for it. ` +
|
|
42
|
+
`Either drop '${locale}' from locales or wire a community bundle (e.g. @byline/i18n-${locale}).`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (errors.length > 0) {
|
|
46
|
+
throw new Error(formatErrors(errors));
|
|
47
|
+
}
|
|
48
|
+
// Soft warnings — key-set drift.
|
|
49
|
+
return { warnings: collectDriftWarnings(translations, locales) };
|
|
50
|
+
}
|
|
51
|
+
function namespaceKeyCount(localeBundle) {
|
|
52
|
+
let count = 0;
|
|
53
|
+
for (const namespace of Object.keys(localeBundle)) {
|
|
54
|
+
count += Object.keys(localeBundle[namespace] ?? {}).length;
|
|
55
|
+
}
|
|
56
|
+
return count;
|
|
57
|
+
}
|
|
58
|
+
function collectDriftWarnings(translations, locales) {
|
|
59
|
+
// For each namespace, take the union of keys across all locales,
|
|
60
|
+
// then report per-locale which keys are missing relative to that
|
|
61
|
+
// union.
|
|
62
|
+
const namespaceKeyUnion = new Map();
|
|
63
|
+
for (const locale of locales) {
|
|
64
|
+
const localeBundle = translations[locale];
|
|
65
|
+
if (localeBundle == null)
|
|
66
|
+
continue;
|
|
67
|
+
for (const namespace of Object.keys(localeBundle)) {
|
|
68
|
+
let union = namespaceKeyUnion.get(namespace);
|
|
69
|
+
if (union == null) {
|
|
70
|
+
union = new Set();
|
|
71
|
+
namespaceKeyUnion.set(namespace, union);
|
|
72
|
+
}
|
|
73
|
+
for (const key of Object.keys(localeBundle[namespace] ?? {})) {
|
|
74
|
+
union.add(key);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const warnings = [];
|
|
79
|
+
for (const locale of locales) {
|
|
80
|
+
const localeBundle = translations[locale];
|
|
81
|
+
if (localeBundle == null)
|
|
82
|
+
continue;
|
|
83
|
+
for (const [namespace, union] of namespaceKeyUnion) {
|
|
84
|
+
const present = new Set(Object.keys(localeBundle[namespace] ?? {}));
|
|
85
|
+
const missing = [];
|
|
86
|
+
for (const key of union) {
|
|
87
|
+
if (!present.has(key))
|
|
88
|
+
missing.push(key);
|
|
89
|
+
}
|
|
90
|
+
if (missing.length > 0) {
|
|
91
|
+
warnings.push({ locale, namespace, missingKeys: missing });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return warnings;
|
|
96
|
+
}
|
|
97
|
+
function formatErrors(errors) {
|
|
98
|
+
return `initBylineCore: i18n translation configuration errors:\n - ${errors.join('\n - ')}`;
|
|
99
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { validateTranslations } from './i18n-validator.js';
|
|
10
|
+
describe('validateTranslations — happy paths', () => {
|
|
11
|
+
it('passes when every declared locale has at least one (namespace, key)', () => {
|
|
12
|
+
expect(() => validateTranslations({
|
|
13
|
+
defaultLocale: 'en',
|
|
14
|
+
locales: ['en', 'fr'],
|
|
15
|
+
translations: {
|
|
16
|
+
en: { 'byline-admin': { foo: 'Foo' } },
|
|
17
|
+
fr: { 'byline-admin': { foo: 'Foo (FR)' } },
|
|
18
|
+
},
|
|
19
|
+
})).not.toThrow();
|
|
20
|
+
});
|
|
21
|
+
it('skips validation entirely when locales is empty (seed scripts, headless tooling)', () => {
|
|
22
|
+
const result = validateTranslations({
|
|
23
|
+
defaultLocale: 'en',
|
|
24
|
+
locales: [],
|
|
25
|
+
});
|
|
26
|
+
expect(result.warnings).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
it('returns no warnings when key sets are aligned across locales', () => {
|
|
29
|
+
const result = validateTranslations({
|
|
30
|
+
defaultLocale: 'en',
|
|
31
|
+
locales: ['en', 'fr'],
|
|
32
|
+
translations: {
|
|
33
|
+
en: { 'byline-admin': { a: 'A', b: 'B' } },
|
|
34
|
+
fr: { 'byline-admin': { a: 'A-fr', b: 'B-fr' } },
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
expect(result.warnings).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('validateTranslations — structural errors', () => {
|
|
41
|
+
it('throws when defaultLocale is not in the permitted locales set', () => {
|
|
42
|
+
expect(() => validateTranslations({
|
|
43
|
+
defaultLocale: 'de',
|
|
44
|
+
locales: ['en', 'fr'],
|
|
45
|
+
translations: {
|
|
46
|
+
en: { 'byline-admin': { a: 'A' } },
|
|
47
|
+
fr: { 'byline-admin': { a: 'A' } },
|
|
48
|
+
},
|
|
49
|
+
})).toThrow(/defaultLocale 'de' is not in i18n\.interface\.locales/);
|
|
50
|
+
});
|
|
51
|
+
it('throws when locales is non-empty but no translations bundle is registered', () => {
|
|
52
|
+
expect(() => validateTranslations({
|
|
53
|
+
defaultLocale: 'en',
|
|
54
|
+
locales: ['en'],
|
|
55
|
+
})).toThrow(/no translations bundle is registered/);
|
|
56
|
+
});
|
|
57
|
+
it('throws when a declared locale has no namespaces in the bundle', () => {
|
|
58
|
+
expect(() => validateTranslations({
|
|
59
|
+
defaultLocale: 'en',
|
|
60
|
+
locales: ['en', 'fr'],
|
|
61
|
+
translations: {
|
|
62
|
+
en: { 'byline-admin': { a: 'A' } },
|
|
63
|
+
// fr declared but no entry in the bundle
|
|
64
|
+
},
|
|
65
|
+
})).toThrow(/'fr' but no translations are registered for it/);
|
|
66
|
+
});
|
|
67
|
+
it('throws when a declared locale has a namespace but zero keys', () => {
|
|
68
|
+
expect(() => validateTranslations({
|
|
69
|
+
defaultLocale: 'en',
|
|
70
|
+
locales: ['en', 'fr'],
|
|
71
|
+
translations: {
|
|
72
|
+
en: { 'byline-admin': { a: 'A' } },
|
|
73
|
+
fr: { 'byline-admin': {} },
|
|
74
|
+
},
|
|
75
|
+
})).toThrow(/'fr' but no translations are registered/);
|
|
76
|
+
});
|
|
77
|
+
it('reports every locale failure in one combined error', () => {
|
|
78
|
+
expect(() => validateTranslations({
|
|
79
|
+
defaultLocale: 'en',
|
|
80
|
+
locales: ['en', 'fr', 'de'],
|
|
81
|
+
translations: {
|
|
82
|
+
en: { 'byline-admin': { a: 'A' } },
|
|
83
|
+
},
|
|
84
|
+
})).toThrow(/'fr' but no translations.*\n.*'de' but no translations/s);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('validateTranslations — soft drift warnings', () => {
|
|
88
|
+
it('flags keys missing in one locale but present in another (same namespace)', () => {
|
|
89
|
+
const result = validateTranslations({
|
|
90
|
+
defaultLocale: 'en',
|
|
91
|
+
locales: ['en', 'fr'],
|
|
92
|
+
translations: {
|
|
93
|
+
en: { 'byline-admin': { a: 'A', b: 'B' } },
|
|
94
|
+
fr: { 'byline-admin': { a: 'A-fr' } },
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
expect(result.warnings).toEqual([
|
|
98
|
+
{ locale: 'fr', namespace: 'byline-admin', missingKeys: ['b'] },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
it('flags drift across multiple namespaces independently', () => {
|
|
102
|
+
const result = validateTranslations({
|
|
103
|
+
defaultLocale: 'en',
|
|
104
|
+
locales: ['en', 'fr'],
|
|
105
|
+
translations: {
|
|
106
|
+
en: {
|
|
107
|
+
'byline-admin': { a: 'A' },
|
|
108
|
+
'plugin-x': { foo: 'Foo', bar: 'Bar' },
|
|
109
|
+
},
|
|
110
|
+
fr: {
|
|
111
|
+
'byline-admin': { a: 'A-fr' },
|
|
112
|
+
'plugin-x': { foo: 'Foo-fr' },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
expect(result.warnings).toEqual([{ locale: 'fr', namespace: 'plugin-x', missingKeys: ['bar'] }]);
|
|
117
|
+
});
|
|
118
|
+
it('reports drift in both directions when neither locale is a superset', () => {
|
|
119
|
+
const result = validateTranslations({
|
|
120
|
+
defaultLocale: 'en',
|
|
121
|
+
locales: ['en', 'fr'],
|
|
122
|
+
translations: {
|
|
123
|
+
en: { 'byline-admin': { a: 'A', b: 'B' } },
|
|
124
|
+
fr: { 'byline-admin': { a: 'A-fr', c: 'C-fr' } },
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
// en is missing 'c' (present in fr); fr is missing 'b' (present in en).
|
|
128
|
+
expect(result.warnings).toEqual(expect.arrayContaining([
|
|
129
|
+
{ locale: 'en', namespace: 'byline-admin', missingKeys: ['c'] },
|
|
130
|
+
{ locale: 'fr', namespace: 'byline-admin', missingKeys: ['b'] },
|
|
131
|
+
]));
|
|
132
|
+
});
|
|
133
|
+
});
|
package/dist/services/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { type DiscoverCounterGroupsInput, discoverCounterGroups, } from './disco
|
|
|
6
6
|
export * from './document-lifecycle.js';
|
|
7
7
|
export * from './document-read.js';
|
|
8
8
|
export * from './field-upload.js';
|
|
9
|
+
export { type InterfaceI18nConfig, type TranslationDriftWarning, type ValidateTranslationsResult, validateTranslations, } from './i18n-validator.js';
|
|
9
10
|
export { type CycleRelationValue, createReadContext, type PopulatedRelationValue, type PopulateFieldOptions, type PopulateFieldSpec, type PopulateMap, type PopulateOptions, type PopulateSpec, populateDocuments, type ReadContext, type UnresolvedRelationValue, } from './populate.js';
|
|
10
11
|
export { buildRelationSummaryPopulateMap, type RelationTargetResolver, resolveRelationProjection, } from './relation-projection.js';
|
|
11
12
|
export { type EmbedRichTextFieldsOptions, embedRichTextFields, resolveEmbedOnSave, } from './richtext-embed.js';
|
package/dist/services/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { discoverCounterGroups, } from './discover-counter-groups.js';
|
|
|
7
7
|
export * from './document-lifecycle.js';
|
|
8
8
|
export * from './document-read.js';
|
|
9
9
|
export * from './field-upload.js';
|
|
10
|
+
export { validateTranslations, } from './i18n-validator.js';
|
|
10
11
|
export { createReadContext, populateDocuments, } from './populate.js';
|
|
11
12
|
export { buildRelationSummaryPopulateMap, resolveRelationProjection, } from './relation-projection.js';
|
|
12
13
|
export { embedRichTextFields, resolveEmbedOnSave, } from './richtext-embed.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Tests for the per-leaf embed walker (`embedRichTextFields`). Focus on
|
|
10
|
+
* the surface owned by core — leaf gating via `embedRelationsOnSave` and
|
|
11
|
+
* branch C (per-leaf error swallow). The visitor-side branches (A / B /
|
|
12
|
+
* found) live in `@byline/richtext-lexical` and have their own suite.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
15
|
+
import { embedRichTextFields } from './richtext-embed.js';
|
|
16
|
+
const noopLogger = {
|
|
17
|
+
log: vi.fn(),
|
|
18
|
+
fatal: vi.fn(),
|
|
19
|
+
error: vi.fn(),
|
|
20
|
+
warn: vi.fn(),
|
|
21
|
+
info: vi.fn(),
|
|
22
|
+
debug: vi.fn(),
|
|
23
|
+
trace: vi.fn(),
|
|
24
|
+
silent: vi.fn(),
|
|
25
|
+
};
|
|
26
|
+
const fakeReadContext = {};
|
|
27
|
+
const richTextValue = (label) => ({
|
|
28
|
+
root: { type: 'root', children: [], _label: label },
|
|
29
|
+
});
|
|
30
|
+
describe('embedRichTextFields', () => {
|
|
31
|
+
it('invokes the embed adapter once per richText leaf, including nested ones', async () => {
|
|
32
|
+
const fields = [
|
|
33
|
+
{ name: 'body', type: 'richText', label: 'Body' },
|
|
34
|
+
{
|
|
35
|
+
name: 'meta',
|
|
36
|
+
type: 'group',
|
|
37
|
+
label: 'Meta',
|
|
38
|
+
fields: [{ name: 'summary', type: 'richText', label: 'Summary' }],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'faq',
|
|
42
|
+
type: 'array',
|
|
43
|
+
label: 'FAQ',
|
|
44
|
+
fields: [{ name: 'answer', type: 'richText', label: 'Answer' }],
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
const data = {
|
|
48
|
+
body: richTextValue('body'),
|
|
49
|
+
meta: { summary: richTextValue('summary') },
|
|
50
|
+
faq: [{ answer: richTextValue('answer-0') }],
|
|
51
|
+
};
|
|
52
|
+
const embed = vi.fn(async () => { });
|
|
53
|
+
await embedRichTextFields({
|
|
54
|
+
fields,
|
|
55
|
+
collectionPath: 'pages',
|
|
56
|
+
data,
|
|
57
|
+
embed,
|
|
58
|
+
readContext: fakeReadContext,
|
|
59
|
+
logger: noopLogger,
|
|
60
|
+
});
|
|
61
|
+
expect(embed).toHaveBeenCalledTimes(3);
|
|
62
|
+
const paths = embed.mock.calls.map((call) => call[0].fieldPath);
|
|
63
|
+
expect(paths.sort()).toEqual(['body', 'faq.0.answer', 'meta.summary']);
|
|
64
|
+
});
|
|
65
|
+
it('skips leaves whose embedRelationsOnSave is explicitly false', async () => {
|
|
66
|
+
const fields = [
|
|
67
|
+
{ name: 'snapshot', type: 'richText', label: 'Snapshot' },
|
|
68
|
+
{
|
|
69
|
+
name: 'thin',
|
|
70
|
+
type: 'richText',
|
|
71
|
+
label: 'Thin',
|
|
72
|
+
embedRelationsOnSave: false,
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
const data = {
|
|
76
|
+
snapshot: richTextValue('snapshot'),
|
|
77
|
+
thin: richTextValue('thin'),
|
|
78
|
+
};
|
|
79
|
+
const embed = vi.fn(async () => { });
|
|
80
|
+
await embedRichTextFields({
|
|
81
|
+
fields,
|
|
82
|
+
collectionPath: 'pages',
|
|
83
|
+
data,
|
|
84
|
+
embed,
|
|
85
|
+
readContext: fakeReadContext,
|
|
86
|
+
logger: noopLogger,
|
|
87
|
+
});
|
|
88
|
+
expect(embed).toHaveBeenCalledTimes(1);
|
|
89
|
+
expect(embed.mock.calls[0]?.[0]).toMatchObject({
|
|
90
|
+
fieldPath: 'snapshot',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
it('swallows per-leaf errors, logs at error level, and keeps walking subsequent leaves (branch C)', async () => {
|
|
94
|
+
const fields = [
|
|
95
|
+
{ name: 'first', type: 'richText', label: 'First' },
|
|
96
|
+
{ name: 'second', type: 'richText', label: 'Second' },
|
|
97
|
+
{ name: 'third', type: 'richText', label: 'Third' },
|
|
98
|
+
];
|
|
99
|
+
const data = {
|
|
100
|
+
first: richTextValue('first'),
|
|
101
|
+
second: richTextValue('second'),
|
|
102
|
+
third: richTextValue('third'),
|
|
103
|
+
};
|
|
104
|
+
const failingErr = new Error('transport down');
|
|
105
|
+
const embed = vi.fn(async (ctx) => {
|
|
106
|
+
if (ctx.fieldPath === 'second')
|
|
107
|
+
throw failingErr;
|
|
108
|
+
});
|
|
109
|
+
const logger = {
|
|
110
|
+
...noopLogger,
|
|
111
|
+
error: vi.fn(),
|
|
112
|
+
};
|
|
113
|
+
await expect(embedRichTextFields({
|
|
114
|
+
fields,
|
|
115
|
+
collectionPath: 'pages',
|
|
116
|
+
data,
|
|
117
|
+
embed,
|
|
118
|
+
readContext: fakeReadContext,
|
|
119
|
+
logger,
|
|
120
|
+
})).resolves.toBeUndefined();
|
|
121
|
+
// All three leaves were attempted — the failure in 'second' did not
|
|
122
|
+
// short-circuit the walk.
|
|
123
|
+
expect(embed).toHaveBeenCalledTimes(3);
|
|
124
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
125
|
+
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({
|
|
126
|
+
err: failingErr,
|
|
127
|
+
collectionPath: 'pages',
|
|
128
|
+
fieldPath: 'second',
|
|
129
|
+
}), expect.stringMatching(/branch C/i));
|
|
130
|
+
// The persisted state for the failing leaf is untouched (the data
|
|
131
|
+
// object the caller passed in is mutated only by the adapter — and
|
|
132
|
+
// here the adapter threw before doing anything).
|
|
133
|
+
expect(data.second).toEqual(richTextValue('second'));
|
|
134
|
+
});
|
|
135
|
+
it('is a no-op when the document carries no richText fields', async () => {
|
|
136
|
+
const fields = [{ name: 'title', type: 'text', label: 'Title' }];
|
|
137
|
+
const data = { title: 'Hello' };
|
|
138
|
+
const embed = vi.fn(async () => { });
|
|
139
|
+
await embedRichTextFields({
|
|
140
|
+
fields,
|
|
141
|
+
collectionPath: 'pages',
|
|
142
|
+
data,
|
|
143
|
+
embed,
|
|
144
|
+
readContext: fakeReadContext,
|
|
145
|
+
logger: noopLogger,
|
|
146
|
+
});
|
|
147
|
+
expect(embed).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -17,6 +17,26 @@
|
|
|
17
17
|
* Pure Zod — no runtime side effects, client-safe.
|
|
18
18
|
*/
|
|
19
19
|
import { z } from 'zod';
|
|
20
|
+
/**
|
|
21
|
+
* Stable error codes emitted by `passwordSchema`. The schema yields
|
|
22
|
+
* these as the Zod issue message rather than free-form English so
|
|
23
|
+
* client callers can translate them at render time. Server-side
|
|
24
|
+
* consumers that don't translate (the defensive request-shape
|
|
25
|
+
* validation in admin command handlers) will see the code itself —
|
|
26
|
+
* acceptable because the form-level validation catches the same
|
|
27
|
+
* case first, so the server message is only surfaced when a
|
|
28
|
+
* malformed payload reaches the API outside the normal flow.
|
|
29
|
+
*
|
|
30
|
+
* Adding a new code: extend the union and add a matching key in the
|
|
31
|
+
* admin-side translator map (`@byline/admin/lib/translate-validation-error`).
|
|
32
|
+
* The same map shape is the recommended pattern for future schemas in
|
|
33
|
+
* this file — emit codes here, translate in `@byline/admin`.
|
|
34
|
+
*/
|
|
35
|
+
export declare const PASSWORD_ERROR_CODES: {
|
|
36
|
+
readonly TOO_SHORT: "password.tooShort";
|
|
37
|
+
readonly TOO_LONG: "password.tooLong";
|
|
38
|
+
readonly COMPLEXITY: "password.complexity";
|
|
39
|
+
};
|
|
20
40
|
/**
|
|
21
41
|
* Standard password policy — 8 to 128 characters, must contain at least
|
|
22
42
|
* one uppercase, one lowercase, one digit, and one character from the
|
|
@@ -17,6 +17,26 @@
|
|
|
17
17
|
* Pure Zod — no runtime side effects, client-safe.
|
|
18
18
|
*/
|
|
19
19
|
import { z } from 'zod';
|
|
20
|
+
/**
|
|
21
|
+
* Stable error codes emitted by `passwordSchema`. The schema yields
|
|
22
|
+
* these as the Zod issue message rather than free-form English so
|
|
23
|
+
* client callers can translate them at render time. Server-side
|
|
24
|
+
* consumers that don't translate (the defensive request-shape
|
|
25
|
+
* validation in admin command handlers) will see the code itself —
|
|
26
|
+
* acceptable because the form-level validation catches the same
|
|
27
|
+
* case first, so the server message is only surfaced when a
|
|
28
|
+
* malformed payload reaches the API outside the normal flow.
|
|
29
|
+
*
|
|
30
|
+
* Adding a new code: extend the union and add a matching key in the
|
|
31
|
+
* admin-side translator map (`@byline/admin/lib/translate-validation-error`).
|
|
32
|
+
* The same map shape is the recommended pattern for future schemas in
|
|
33
|
+
* this file — emit codes here, translate in `@byline/admin`.
|
|
34
|
+
*/
|
|
35
|
+
export const PASSWORD_ERROR_CODES = {
|
|
36
|
+
TOO_SHORT: 'password.tooShort',
|
|
37
|
+
TOO_LONG: 'password.tooLong',
|
|
38
|
+
COMPLEXITY: 'password.complexity',
|
|
39
|
+
};
|
|
20
40
|
/**
|
|
21
41
|
* Standard password policy — 8 to 128 characters, must contain at least
|
|
22
42
|
* one uppercase, one lowercase, one digit, and one character from the
|
|
@@ -28,9 +48,9 @@ import { z } from 'zod';
|
|
|
28
48
|
*/
|
|
29
49
|
export const passwordSchema = z
|
|
30
50
|
.string()
|
|
31
|
-
.min(8,
|
|
32
|
-
.max(128,
|
|
33
|
-
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/,
|
|
51
|
+
.min(8, PASSWORD_ERROR_CODES.TOO_SHORT)
|
|
52
|
+
.max(128, PASSWORD_ERROR_CODES.TOO_LONG)
|
|
53
|
+
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/, PASSWORD_ERROR_CODES.COMPLEXITY);
|
|
34
54
|
/**
|
|
35
55
|
* UUID with a descriptive error message. Equivalent to `z.uuid()` but
|
|
36
56
|
* the message is caller-friendly for forms that surface field errors
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/core",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.6.1",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"sharp": "^0.34.5",
|
|
80
80
|
"uuid": "^14.0.0",
|
|
81
81
|
"zod": "^4.4.3",
|
|
82
|
-
"@byline/auth": "2.
|
|
82
|
+
"@byline/auth": "2.6.1"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|