@byline/core 2.5.1 → 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
@@ -120,23 +120,35 @@ export interface CombinatorFilter {
120
120
  children: DocumentFilter[];
121
121
  }
122
122
  /**
123
- * A predicate over a document-version column (`status`, `path`). Distinct
124
- * from `FieldFilter` — these columns live on `document_versions` itself,
125
- * not on the EAV stores, so they compile to a direct outer-scope column
126
- * comparison rather than an `EXISTS` subquery.
123
+ * A predicate over a document-version column (`status`, `path`, `id`).
124
+ * Distinct from `FieldFilter` — these columns live on `document_versions`
125
+ * itself (or the current-documents view), not on the EAV stores, so they
126
+ * compile to a direct outer-scope column comparison rather than an `EXISTS`
127
+ * subquery.
127
128
  *
128
- * Produced by `parse-where` when `status` / `path` appear *inside* a
129
- * combinator (`$or` / `$and` child). At the top level the same keys are
130
- * intercepted as reserved keys on `ParsedWhere.status` / `ParsedWhere.pathFilter`
131
- * because they map to direct adapter parameters there; inside a
132
- * combinator that mapping no longer makes sense (you can't OR-combine
133
- * with the outer scalar parameter), so they downshift to this filter.
129
+ * Produced by `parse-where` for two reasons:
130
+ *
131
+ * - `status` / `path` appearing *inside* a combinator (`$or` / `$and`
132
+ * child) or inside a relation sub-clause. At the top level the same
133
+ * keys are intercepted as `ParsedWhere.status` / `ParsedWhere.pathFilter`
134
+ * because they map to direct adapter parameters there; inside a
135
+ * combinator that mapping no longer makes sense (you can't OR-combine
136
+ * with the outer scalar parameter), so they downshift to this filter.
137
+ *
138
+ * - `id` at *any* scope. Unlike `status` (single equality, used in many
139
+ * non-filter call sites) and `path` (needs the `pathProjection` join
140
+ * against `byline_document_paths`), `id` is a plain column on the
141
+ * current-documents view comparable directly at every scope. Skipping
142
+ * a top-level scalar form keeps the surface area small.
143
+ *
144
+ * `value` is widened beyond `string | null` so the `$in` / `$nin` operators
145
+ * (the headline use case for `id`) can carry array operands.
134
146
  */
135
147
  export interface DocumentColumnFilter {
136
148
  kind: 'docColumn';
137
- column: 'status' | 'path';
149
+ column: 'status' | 'path' | 'id';
138
150
  operator: FieldFilterOperator;
139
- value: string | null;
151
+ value: string | number | boolean | null | Array<string | number>;
140
152
  }
141
153
  /**
142
154
  * Any filter that can appear in a `findDocuments` call — a direct field
@@ -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);
@@ -10,7 +10,7 @@ import { fieldTypeToStore } from '../storage/field-store-map.js';
10
10
  // Document-level reserved keys
11
11
  // ---------------------------------------------------------------------------
12
12
  /** Where clause keys that map to document-level columns, not EAV stores. */
13
- const DOCUMENT_LEVEL_KEYS = new Set(['status', 'path', 'query']);
13
+ const DOCUMENT_LEVEL_KEYS = new Set(['status', 'path', 'query', 'id']);
14
14
  // ---------------------------------------------------------------------------
15
15
  // Document-level sort columns
16
16
  // ---------------------------------------------------------------------------
@@ -225,6 +225,26 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
225
225
  }
226
226
  continue;
227
227
  }
228
+ // `id` always downshifts to a `DocumentColumnFilter` — no top-level
229
+ // scalar form. Unlike `status` (single equality, used in many non-filter
230
+ // call sites) and `path` (needs the `pathProjection` join), `id` is a
231
+ // plain column on the current-documents view comparable directly at any
232
+ // scope. Unifying all three scopes through the same downshift keeps the
233
+ // surface area small. Note: the value is passed through verbatim — we
234
+ // don't `String(...)` it the way status/path do, because the headline
235
+ // use case is `$in` / `$nin` carrying an array of document ids.
236
+ if (key === 'id') {
237
+ const parsed = normaliseToOperator(rawValue);
238
+ if (parsed) {
239
+ result.filters.push({
240
+ kind: 'docColumn',
241
+ column: 'id',
242
+ operator: parsed.operator,
243
+ value: parsed.value,
244
+ });
245
+ }
246
+ continue;
247
+ }
228
248
  // --- Field-level keys --------------------------------------------------
229
249
  const field = definition.fields.find((f) => f.name === key);
230
250
  if (!field)
@@ -625,6 +625,65 @@ describe('parseWhere — combinators', () => {
625
625
  });
626
626
  });
627
627
  // ---------------------------------------------------------------------------
628
+ // parseWhere — `id` reserved key
629
+ // ---------------------------------------------------------------------------
630
+ //
631
+ // `id` is the logical document id (`document_id` on the current-documents
632
+ // view). It is reserved at every scope and always downshifts to a
633
+ // `DocumentColumnFilter` — no top-level scalar form like `status` /
634
+ // `pathFilter`, because `id` is a plain column comparable directly.
635
+ describe('parseWhere — `id` reserved key', () => {
636
+ it('emits a docColumn filter for a bare top-level `id` value', async () => {
637
+ const result = await parseWhere({ id: 'doc-123' }, testCollection);
638
+ expect(result.filters).toEqual([
639
+ { kind: 'docColumn', column: 'id', operator: '$eq', value: 'doc-123' },
640
+ ]);
641
+ });
642
+ it('emits a docColumn filter with array value for top-level `id: { $in }`', async () => {
643
+ // The headline use case — batch lookup by a list of document ids.
644
+ // Value passes through verbatim (no `String(...)` coercion) so the
645
+ // array survives to the adapter's `$in` SQL builder.
646
+ const ids = ['doc-1', 'doc-2', 'doc-3'];
647
+ const result = await parseWhere({ id: { $in: ids } }, testCollection);
648
+ expect(result.filters).toEqual([
649
+ { kind: 'docColumn', column: 'id', operator: '$in', value: ids },
650
+ ]);
651
+ });
652
+ it('emits a docColumn filter for `id` inside an $or combinator', async () => {
653
+ const result = await parseWhere({ $or: [{ id: 'doc-a' }, { id: 'doc-b' }] }, testCollection);
654
+ expect(result.filters).toHaveLength(1);
655
+ expect(result.filters[0]).toMatchObject({
656
+ kind: 'or',
657
+ children: [
658
+ { kind: 'docColumn', column: 'id', operator: '$eq', value: 'doc-a' },
659
+ { kind: 'docColumn', column: 'id', operator: '$eq', value: 'doc-b' },
660
+ ],
661
+ });
662
+ });
663
+ it('emits a docColumn filter for `id` inside a nested relation sub-where', async () => {
664
+ // Reserved-key precedence — same as `status` / `path`: inside a
665
+ // relation hop, `id` refers to the target version's document_id, not
666
+ // a field of the same name.
667
+ const result = await parseWhere({ category: { id: 'cat-news' } }, testCollection, ctx);
668
+ expect(result.filters).toHaveLength(1);
669
+ expect(result.filters[0]).toEqual({
670
+ kind: 'relation',
671
+ fieldName: 'category',
672
+ targetCollectionId: 'id-test-categories',
673
+ nested: [{ kind: 'docColumn', column: 'id', operator: '$eq', value: 'cat-news' }],
674
+ });
675
+ });
676
+ it('does NOT populate any top-level scalar slot for `id`', async () => {
677
+ // Sanity: unlike status / pathFilter, `id` has no top-level scalar
678
+ // form on `ParsedWhere`. Always lives in `filters[]` as a docColumn.
679
+ const result = await parseWhere({ id: 'doc-x' }, testCollection);
680
+ expect(result.status).toBeUndefined();
681
+ expect(result.pathFilter).toBeUndefined();
682
+ expect(result.query).toBeUndefined();
683
+ expect(result.filters).toHaveLength(1);
684
+ });
685
+ });
686
+ // ---------------------------------------------------------------------------
628
687
  // mergePredicates
629
688
  // ---------------------------------------------------------------------------
630
689
  describe('mergePredicates', () => {
@@ -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.1",
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.1"
82
+ "@byline/auth": "2.6.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",