@byline/core 2.5.2 → 2.6.0

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.
@@ -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 defaults to `/${collectionPath}/${doc.path}` — fine
220
- * for collections whose public URL mirrors the collection path.
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
+ });
@@ -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';
@@ -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, 'Password must be at least 8 characters long.')
32
- .max(128, 'Password must not exceed 128 characters.')
33
- .regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one character from the following: #?!@$%^&*-');
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.2",
5
+ "version": "2.6.0",
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.5.2"
82
+ "@byline/auth": "2.6.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",