@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.
- package/dist/@types/admin-types.d.ts +5 -2
- package/dist/@types/db-types.d.ts +24 -12
- package/dist/@types/site-config.d.ts +44 -0
- package/dist/core.js +21 -0
- package/dist/query/parse-where.js +21 -1
- package/dist/query/parse-where.test.node.js +59 -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
|
|
@@ -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`).
|
|
124
|
-
* from `FieldFilter` — these columns live on `document_versions`
|
|
125
|
-
* not on the EAV stores, so they
|
|
126
|
-
* comparison rather than an `EXISTS`
|
|
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`
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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
|
+
});
|
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.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.
|
|
82
|
+
"@byline/auth": "2.6.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|