@byline/admin 3.17.0 → 3.18.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.
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { getClientConfig } from "@byline/core";
4
5
  import { useTranslation } from "@byline/i18n/react";
5
6
  import { Alert, Button, ComboButton } from "@byline/ui/react";
6
7
  import classnames from "classnames";
@@ -32,6 +33,15 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
32
33
  const [pendingSystemFieldsSubmit, setPendingSystemFieldsSubmit] = useState(null);
33
34
  const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale);
34
35
  const { uploadField } = useBylineFieldServices();
36
+ const pathSlugifier = getClientConfig().slugifier;
37
+ const pathSourceLocked = useMemo(()=>{
38
+ if (!useAsPath) return false;
39
+ const source = fields.find((f)=>f.name === useAsPath);
40
+ return null != source && ('counter' === source.type || true === source.readOnly);
41
+ }, [
42
+ useAsPath,
43
+ fields
44
+ ]);
35
45
  useEffect(()=>{
36
46
  if (initialLocale) setContentLocale(initialLocale);
37
47
  }, [
@@ -345,7 +355,9 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
345
355
  collectionPath: collectionPath ?? '',
346
356
  defaultLocale: defaultLocale,
347
357
  activeLocale: contentLocale,
348
- mode: mode
358
+ mode: mode,
359
+ slugifier: pathSlugifier,
360
+ sourceLocked: pathSourceLocked
349
361
  }),
350
362
  tree && 'edit' === mode && 'string' == typeof initialData?.id && /*#__PURE__*/ jsx(TreePlacementWidget, {
351
363
  collectionPath: collectionPath ?? '',
@@ -1,3 +1,4 @@
1
+ import type { SlugifierFn } from '@byline/core';
1
2
  export interface PathWidgetProps {
2
3
  /** The collection's `useAsPath` source field name, when configured. */
3
4
  useAsPath: string | undefined;
@@ -16,6 +17,23 @@ export interface PathWidgetProps {
16
17
  activeLocale: string;
17
18
  /** `'create'` shows the live derived preview as placeholder text. */
18
19
  mode: 'create' | 'edit';
20
+ /**
21
+ * Installation slugifier used to derive the live preview. Must match the
22
+ * server-side `ServerConfig.slugifier` so the preview agrees with what is
23
+ * persisted. Defaults to the built-in `slugify` when omitted — callers
24
+ * that keep the default slugifier need not pass this.
25
+ */
26
+ slugifier?: SlugifierFn;
27
+ /**
28
+ * When `true`, the `useAsPath` source field's value is not editable through
29
+ * this form (e.g. it is an allocator-assigned `counter`, or an otherwise
30
+ * read-only field). Its value is either server-assigned — and so not
31
+ * reproducible client-side — or simply cannot change, which means the
32
+ * source-derived live preview and the "Regenerate" affordance are
33
+ * meaningless. When set, the widget suppresses both and just shows the
34
+ * persisted path. Defaults to `false`.
35
+ */
36
+ sourceLocked?: boolean;
19
37
  }
20
38
  /**
21
39
  * System-managed `path` widget.
@@ -33,4 +51,4 @@ export interface PathWidgetProps {
33
51
  * Stable override handles: `.byline-form-path`, `.byline-form-path-header`,
34
52
  * `.byline-form-path-regenerate`.
35
53
  */
36
- export declare const PathWidget: ({ useAsPath, collectionPath, defaultLocale, activeLocale, mode, }: PathWidgetProps) => import("react").JSX.Element;
54
+ export declare const PathWidget: ({ useAsPath, collectionPath, defaultLocale, activeLocale, mode, slugifier, sourceLocked, }: PathWidgetProps) => import("react").JSX.Element;
@@ -12,25 +12,28 @@ function coerceToString(value) {
12
12
  if (value instanceof Date) return value.toISOString();
13
13
  return String(value);
14
14
  }
15
- const PathWidget = ({ useAsPath, collectionPath, defaultLocale, activeLocale, mode })=>{
15
+ const PathWidget = ({ useAsPath, collectionPath, defaultLocale, activeLocale, mode, slugifier, sourceLocked = false })=>{
16
16
  const { setSystemPath } = useFormContext();
17
17
  const { t } = useTranslation('byline-admin');
18
18
  const systemPath = useSystemPath();
19
19
  const sourceValue = useFieldValue(useAsPath ?? '');
20
+ const runSlugify = slugifier ?? slugify;
20
21
  const isReadOnly = activeLocale !== defaultLocale;
21
22
  const livePreview = useMemo(()=>{
22
- if (!useAsPath) return '';
23
+ if (!useAsPath || sourceLocked) return '';
23
24
  const asString = coerceToString(sourceValue);
24
25
  if (0 === asString.length) return '';
25
- return slugify(asString, {
26
+ return runSlugify(asString, {
26
27
  locale: defaultLocale,
27
28
  collectionPath
28
29
  });
29
30
  }, [
30
31
  useAsPath,
32
+ sourceLocked,
31
33
  sourceValue,
32
34
  defaultLocale,
33
- collectionPath
35
+ collectionPath,
36
+ runSlugify
34
37
  ]);
35
38
  const inputValue = systemPath ?? '';
36
39
  const handleChange = useCallback((next)=>{
@@ -46,14 +49,15 @@ const PathWidget = ({ useAsPath, collectionPath, defaultLocale, activeLocale, mo
46
49
  ]);
47
50
  const formatted = useMemo(()=>{
48
51
  if (0 === inputValue.length) return '';
49
- return slugify(inputValue, {
52
+ return runSlugify(inputValue, {
50
53
  locale: defaultLocale,
51
54
  collectionPath
52
55
  });
53
56
  }, [
54
57
  inputValue,
55
58
  defaultLocale,
56
- collectionPath
59
+ collectionPath,
60
+ runSlugify
57
61
  ]);
58
62
  const validationHint = inputValue.length > 0 && formatted !== inputValue ? t('pathWidget.suggestedHint', {
59
63
  formatted
@@ -20,8 +20,8 @@
20
20
  * code per condition.
21
21
  */
22
22
  export declare const AdminAccountErrorCodes: {
23
- readonly NOT_FOUND: "admin.account.notFound";
24
- readonly INVALID_CURRENT_PASSWORD: "admin.account.invalidCurrentPassword";
23
+ readonly NOT_FOUND: 'admin.account.notFound';
24
+ readonly INVALID_CURRENT_PASSWORD: 'admin.account.invalidCurrentPassword';
25
25
  };
26
26
  export type AdminAccountErrorCode = (typeof AdminAccountErrorCodes)[keyof typeof AdminAccountErrorCodes];
27
27
  export interface AdminAccountErrorOptions {
@@ -20,7 +20,7 @@ import type { AbilityRegistry } from '@byline/auth';
20
20
  * append-only and is written by the lifecycle write-points, never edited.
21
21
  */
22
22
  export declare const ADMIN_ACTIVITY_ABILITIES: {
23
- readonly read: "admin.activity.read";
23
+ readonly read: 'admin.activity.read';
24
24
  };
25
25
  export type AdminActivityAbilityKey = (typeof ADMIN_ACTIVITY_ABILITIES)[keyof typeof ADMIN_ACTIVITY_ABILITIES];
26
26
  /**
@@ -19,8 +19,8 @@ import type { AbilityRegistry } from '@byline/auth';
19
19
  * every permission-managing role.
20
20
  */
21
21
  export declare const ADMIN_PERMISSIONS_ABILITIES: {
22
- readonly read: "admin.permissions.read";
23
- readonly update: "admin.permissions.update";
22
+ readonly read: 'admin.permissions.read';
23
+ readonly update: 'admin.permissions.update';
24
24
  };
25
25
  export type AdminPermissionsAbilityKey = (typeof ADMIN_PERMISSIONS_ABILITIES)[keyof typeof ADMIN_PERMISSIONS_ABILITIES];
26
26
  export declare function registerAdminPermissionsAbilities(registry: AbilityRegistry): void;
@@ -14,8 +14,8 @@
14
14
  * inspector is read-only and never throws either of these.
15
15
  */
16
16
  export declare const AdminPermissionsErrorCodes: {
17
- readonly ROLE_NOT_FOUND: "admin.permissions.roleNotFound";
18
- readonly ABILITY_UNREGISTERED: "admin.permissions.abilityUnregistered";
17
+ readonly ROLE_NOT_FOUND: 'admin.permissions.roleNotFound';
18
+ readonly ABILITY_UNREGISTERED: 'admin.permissions.abilityUnregistered';
19
19
  };
20
20
  export type AdminPermissionsErrorCode = (typeof AdminPermissionsErrorCodes)[keyof typeof AdminPermissionsErrorCodes];
21
21
  export interface AdminPermissionsErrorOptions {
@@ -29,8 +29,8 @@ export declare const abilityDescriptorResponseSchema: z.ZodObject<{
29
29
  source: z.ZodNullable<z.ZodEnum<{
30
30
  admin: "admin";
31
31
  collection: "collection";
32
- plugin: "plugin";
33
32
  core: "core";
33
+ plugin: "plugin";
34
34
  }>>;
35
35
  }, z.core.$strip>;
36
36
  export type AbilityDescriptorResponse = z.infer<typeof abilityDescriptorResponseSchema>;
@@ -44,8 +44,8 @@ export declare const abilityGroupResponseSchema: z.ZodObject<{
44
44
  source: z.ZodNullable<z.ZodEnum<{
45
45
  admin: "admin";
46
46
  collection: "collection";
47
- plugin: "plugin";
48
47
  core: "core";
48
+ plugin: "plugin";
49
49
  }>>;
50
50
  }, z.core.$strip>>;
51
51
  }, z.core.$strip>;
@@ -63,8 +63,8 @@ export declare const listRegisteredAbilitiesResponseSchema: z.ZodObject<{
63
63
  source: z.ZodNullable<z.ZodEnum<{
64
64
  admin: "admin";
65
65
  collection: "collection";
66
- plugin: "plugin";
67
66
  core: "core";
67
+ plugin: "plugin";
68
68
  }>>;
69
69
  }, z.core.$strip>>;
70
70
  groups: z.ZodArray<z.ZodObject<{
@@ -77,8 +77,8 @@ export declare const listRegisteredAbilitiesResponseSchema: z.ZodObject<{
77
77
  source: z.ZodNullable<z.ZodEnum<{
78
78
  admin: "admin";
79
79
  collection: "collection";
80
- plugin: "plugin";
81
80
  core: "core";
81
+ plugin: "plugin";
82
82
  }>>;
83
83
  }, z.core.$strip>>;
84
84
  }, z.core.$strip>>;
@@ -18,10 +18,10 @@ import type { AbilityRegistry } from '@byline/auth';
18
18
  * keys there.
19
19
  */
20
20
  export declare const ADMIN_ROLES_ABILITIES: {
21
- readonly read: "admin.roles.read";
22
- readonly create: "admin.roles.create";
23
- readonly update: "admin.roles.update";
24
- readonly delete: "admin.roles.delete";
21
+ readonly read: 'admin.roles.read';
22
+ readonly create: 'admin.roles.create';
23
+ readonly update: 'admin.roles.update';
24
+ readonly delete: 'admin.roles.delete';
25
25
  };
26
26
  export type AdminRolesAbilityKey = (typeof ADMIN_ROLES_ABILITIES)[keyof typeof ADMIN_ROLES_ABILITIES];
27
27
  /**
@@ -13,10 +13,10 @@
13
13
  * ability keys in logs and admin UI messages.
14
14
  */
15
15
  export declare const AdminRolesErrorCodes: {
16
- readonly NOT_FOUND: "admin.roles.notFound";
17
- readonly MACHINE_NAME_IN_USE: "admin.roles.machineNameInUse";
18
- readonly VERSION_CONFLICT: "admin.roles.versionConflict";
19
- readonly USER_NOT_FOUND: "admin.roles.userNotFound";
16
+ readonly NOT_FOUND: 'admin.roles.notFound';
17
+ readonly MACHINE_NAME_IN_USE: 'admin.roles.machineNameInUse';
18
+ readonly VERSION_CONFLICT: 'admin.roles.versionConflict';
19
+ readonly USER_NOT_FOUND: 'admin.roles.userNotFound';
20
20
  };
21
21
  export type AdminRolesErrorCode = (typeof AdminRolesErrorCodes)[keyof typeof AdminRolesErrorCodes];
22
22
  export interface AdminRolesErrorOptions {
@@ -25,11 +25,11 @@ import type { AbilityRegistry } from '@byline/auth';
25
25
  * are strictly for administering other admin users.
26
26
  */
27
27
  export declare const ADMIN_USERS_ABILITIES: {
28
- readonly read: "admin.users.read";
29
- readonly create: "admin.users.create";
30
- readonly update: "admin.users.update";
31
- readonly delete: "admin.users.delete";
32
- readonly changePassword: "admin.users.changePassword";
28
+ readonly read: 'admin.users.read';
29
+ readonly create: 'admin.users.create';
30
+ readonly update: 'admin.users.update';
31
+ readonly delete: 'admin.users.delete';
32
+ readonly changePassword: 'admin.users.changePassword';
33
33
  };
34
34
  export type AdminUsersAbilityKey = (typeof ADMIN_USERS_ABILITIES)[keyof typeof ADMIN_USERS_ABILITIES];
35
35
  /**
@@ -18,11 +18,11 @@
18
18
  * alongside the matching ability keys in logs and admin UI messages.
19
19
  */
20
20
  export declare const AdminUsersErrorCodes: {
21
- readonly NOT_FOUND: "admin.users.notFound";
22
- readonly EMAIL_IN_USE: "admin.users.emailInUse";
23
- readonly SELF_DELETE_FORBIDDEN: "admin.users.selfDeleteForbidden";
24
- readonly SELF_DISABLE_FORBIDDEN: "admin.users.selfDisableForbidden";
25
- readonly VERSION_CONFLICT: "admin.users.versionConflict";
21
+ readonly NOT_FOUND: 'admin.users.notFound';
22
+ readonly EMAIL_IN_USE: 'admin.users.emailInUse';
23
+ readonly SELF_DELETE_FORBIDDEN: 'admin.users.selfDeleteForbidden';
24
+ readonly SELF_DISABLE_FORBIDDEN: 'admin.users.selfDisableForbidden';
25
+ readonly VERSION_CONFLICT: 'admin.users.versionConflict';
26
26
  };
27
27
  export type AdminUsersErrorCode = (typeof AdminUsersErrorCodes)[keyof typeof AdminUsersErrorCodes];
28
28
  export interface AdminUsersErrorOptions {
@@ -11,12 +11,12 @@ export declare const listAdminUsersRequestSchema: z.ZodObject<{
11
11
  pageSize: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
12
12
  query: z.ZodOptional<z.ZodString>;
13
13
  order: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
14
+ created_at: "created_at";
14
15
  email: "email";
15
- given_name: "given_name";
16
16
  family_name: "family_name";
17
- username: "username";
18
- created_at: "created_at";
17
+ given_name: "given_name";
19
18
  updated_at: "updated_at";
19
+ username: "username";
20
20
  }>>>;
21
21
  desc: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
22
22
  }, z.core.$strip>;
@@ -121,12 +121,12 @@ export declare const adminUserListResponseSchema: z.ZodObject<{
121
121
  page_size: z.ZodNumber;
122
122
  query: z.ZodString;
123
123
  order: z.ZodEnum<{
124
+ created_at: "created_at";
124
125
  email: "email";
125
- given_name: "given_name";
126
126
  family_name: "family_name";
127
- username: "username";
128
- created_at: "created_at";
127
+ given_name: "given_name";
129
128
  updated_at: "updated_at";
129
+ username: "username";
130
130
  }>;
131
131
  desc: z.ZodBoolean;
132
132
  }, z.core.$strip>;
@@ -1,7 +1,7 @@
1
1
  import { BSIGMA, G1s, G2s } from "./_blake.js";
2
2
  import { SHA256_IV } from "./_md.js";
3
3
  import { abytes, aexists, anumber, aoutput, clean, createHasher, swap32IfBE, swap8IfBE, u32 } from "./utils.js";
4
- import * as __rspack_external__u64_js_3c1fe104 from "./_u64.js";
4
+ import * as __rspack_external__u64_js_5917676b from "./_u64.js";
5
5
  const B2B_IV = /* @__PURE__ */ Uint32Array.from([
6
6
  0xf3bcc908,
7
7
  0x6a09e667,
@@ -27,25 +27,25 @@ function G1b(a, b, c, d, msg, x) {
27
27
  let Bl = BBUF[2 * b], Bh = BBUF[2 * b + 1];
28
28
  let Cl = BBUF[2 * c], Ch = BBUF[2 * c + 1];
29
29
  let Dl = BBUF[2 * d], Dh = BBUF[2 * d + 1];
30
- let ll = __rspack_external__u64_js_3c1fe104.add3L(Al, Bl, Xl);
31
- Ah = __rspack_external__u64_js_3c1fe104.add3H(ll, Ah, Bh, Xh);
30
+ let ll = __rspack_external__u64_js_5917676b.add3L(Al, Bl, Xl);
31
+ Ah = __rspack_external__u64_js_5917676b.add3H(ll, Ah, Bh, Xh);
32
32
  Al = 0 | ll;
33
33
  ({ Dh, Dl } = {
34
34
  Dh: Dh ^ Ah,
35
35
  Dl: Dl ^ Al
36
36
  });
37
37
  ({ Dh, Dl } = {
38
- Dh: __rspack_external__u64_js_3c1fe104.rotr32H(Dh, Dl),
39
- Dl: __rspack_external__u64_js_3c1fe104.rotr32L(Dh, Dl)
38
+ Dh: __rspack_external__u64_js_5917676b.rotr32H(Dh, Dl),
39
+ Dl: __rspack_external__u64_js_5917676b.rotr32L(Dh, Dl)
40
40
  });
41
- ({ h: Ch, l: Cl } = __rspack_external__u64_js_3c1fe104.add(Ch, Cl, Dh, Dl));
41
+ ({ h: Ch, l: Cl } = __rspack_external__u64_js_5917676b.add(Ch, Cl, Dh, Dl));
42
42
  ({ Bh, Bl } = {
43
43
  Bh: Bh ^ Ch,
44
44
  Bl: Bl ^ Cl
45
45
  });
46
46
  ({ Bh, Bl } = {
47
- Bh: __rspack_external__u64_js_3c1fe104.rotrSH(Bh, Bl, 24),
48
- Bl: __rspack_external__u64_js_3c1fe104.rotrSL(Bh, Bl, 24)
47
+ Bh: __rspack_external__u64_js_5917676b.rotrSH(Bh, Bl, 24),
48
+ Bl: __rspack_external__u64_js_5917676b.rotrSL(Bh, Bl, 24)
49
49
  });
50
50
  BBUF[2 * a] = Al, BBUF[2 * a + 1] = Ah;
51
51
  BBUF[2 * b] = Bl, BBUF[2 * b + 1] = Bh;
@@ -58,25 +58,25 @@ function G2b(a, b, c, d, msg, x) {
58
58
  let Bl = BBUF[2 * b], Bh = BBUF[2 * b + 1];
59
59
  let Cl = BBUF[2 * c], Ch = BBUF[2 * c + 1];
60
60
  let Dl = BBUF[2 * d], Dh = BBUF[2 * d + 1];
61
- let ll = __rspack_external__u64_js_3c1fe104.add3L(Al, Bl, Xl);
62
- Ah = __rspack_external__u64_js_3c1fe104.add3H(ll, Ah, Bh, Xh);
61
+ let ll = __rspack_external__u64_js_5917676b.add3L(Al, Bl, Xl);
62
+ Ah = __rspack_external__u64_js_5917676b.add3H(ll, Ah, Bh, Xh);
63
63
  Al = 0 | ll;
64
64
  ({ Dh, Dl } = {
65
65
  Dh: Dh ^ Ah,
66
66
  Dl: Dl ^ Al
67
67
  });
68
68
  ({ Dh, Dl } = {
69
- Dh: __rspack_external__u64_js_3c1fe104.rotrSH(Dh, Dl, 16),
70
- Dl: __rspack_external__u64_js_3c1fe104.rotrSL(Dh, Dl, 16)
69
+ Dh: __rspack_external__u64_js_5917676b.rotrSH(Dh, Dl, 16),
70
+ Dl: __rspack_external__u64_js_5917676b.rotrSL(Dh, Dl, 16)
71
71
  });
72
- ({ h: Ch, l: Cl } = __rspack_external__u64_js_3c1fe104.add(Ch, Cl, Dh, Dl));
72
+ ({ h: Ch, l: Cl } = __rspack_external__u64_js_5917676b.add(Ch, Cl, Dh, Dl));
73
73
  ({ Bh, Bl } = {
74
74
  Bh: Bh ^ Ch,
75
75
  Bl: Bl ^ Cl
76
76
  });
77
77
  ({ Bh, Bl } = {
78
- Bh: __rspack_external__u64_js_3c1fe104.rotrBH(Bh, Bl, 63),
79
- Bl: __rspack_external__u64_js_3c1fe104.rotrBL(Bh, Bl, 63)
78
+ Bh: __rspack_external__u64_js_5917676b.rotrBH(Bh, Bl, 63),
79
+ Bl: __rspack_external__u64_js_5917676b.rotrBL(Bh, Bl, 63)
80
80
  });
81
81
  BBUF[2 * a] = Al, BBUF[2 * a + 1] = Ah;
82
82
  BBUF[2 * b] = Bl, BBUF[2 * b + 1] = Bh;
@@ -226,7 +226,7 @@ class _BLAKE2b extends _BLAKE2 {
226
226
  compress(msg, offset, isLast) {
227
227
  this.get().forEach((v, i)=>BBUF[i] = v);
228
228
  BBUF.set(B2B_IV, 16);
229
- let { h, l } = __rspack_external__u64_js_3c1fe104.fromBig(BigInt(this.length));
229
+ let { h, l } = __rspack_external__u64_js_5917676b.fromBig(BigInt(this.length));
230
230
  BBUF[24] = B2B_IV[8] ^ l;
231
231
  BBUF[25] = B2B_IV[9] ^ h;
232
232
  if (isLast) {
@@ -376,7 +376,7 @@ class _BLAKE2s extends _BLAKE2 {
376
376
  this.v7 = 0 | v7;
377
377
  }
378
378
  compress(msg, offset, isLast) {
379
- const { h, l } = __rspack_external__u64_js_3c1fe104.fromBig(BigInt(this.length));
379
+ const { h, l } = __rspack_external__u64_js_5917676b.fromBig(BigInt(this.length));
380
380
  const { v0, v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15 } = compress(BSIGMA, offset, msg, 10, this.v0, this.v1, this.v2, this.v3, this.v4, this.v5, this.v6, this.v7, B2S_IV[0], B2S_IV[1], B2S_IV[2], B2S_IV[3], l ^ B2S_IV[4], h ^ B2S_IV[5], isLast ? ~B2S_IV[6] : B2S_IV[6], B2S_IV[7]);
381
381
  this.v0 ^= v0 ^ v8;
382
382
  this.v1 ^= v1 ^ v9;
@@ -10,21 +10,7 @@ export interface SourceLocaleBadgeProps {
10
10
  locale: string;
11
11
  className?: string;
12
12
  }
13
- /**
14
- * Small neutral badge indicating a document's content **source locale** — the
15
- * locale it was authored in (its anchor: fallback floor, path locale,
16
- * completeness yardstick). Rendered next to the document title in the edit and
17
- * list views. Mirrors the localized-field {@link LocaleBadge} in spirit but uses
18
- * the shared {@link Badge} with the `noeffect` (neutral) intent.
19
- *
20
- * NOTE: currently rendered for *every* document so the anchor is visible during
21
- * development. The intended end state is to show it only when `locale` differs
22
- * from the system's current default content locale (a normal single-default
23
- * install then shows nothing). See docs/07-internationalization/index.md.
24
- *
25
- * Stable override handle: `.byline-source-locale-badge`.
26
- */
27
- export declare const SourceLocaleBadge: {
28
- ({ locale, className }: SourceLocaleBadgeProps): import("react").JSX.Element;
29
- displayName: string;
30
- };
13
+ export declare function SourceLocaleBadge({ locale, className }: SourceLocaleBadgeProps): import("react").JSX.Element;
14
+ export declare namespace SourceLocaleBadge {
15
+ var displayName: string;
16
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/admin",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.17.0",
5
+ "version": "3.18.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -153,31 +153,31 @@
153
153
  "classnames": "^2.5.1",
154
154
  "jose": "^6.2.3",
155
155
  "react-diff-viewer-continued": "^4.2.2",
156
- "uuid": "^14.0.0",
156
+ "uuid": "^14.0.1",
157
157
  "zod": "^4.4.3",
158
- "@byline/auth": "3.17.0",
159
- "@byline/i18n": "3.17.0",
160
- "@byline/core": "3.17.0",
161
- "@byline/ui": "3.17.0"
158
+ "@byline/auth": "3.18.0",
159
+ "@byline/i18n": "3.18.0",
160
+ "@byline/ui": "3.18.0",
161
+ "@byline/core": "3.18.0"
162
162
  },
163
163
  "peerDependencies": {
164
164
  "react": "^19.0.0",
165
165
  "react-dom": "^19.0.0"
166
166
  },
167
167
  "devDependencies": {
168
- "@biomejs/biome": "2.4.15",
169
- "@rsbuild/plugin-react": "^2.0.0",
170
- "@rslib/core": "^0.21.5",
171
- "@types/node": "^25.9.1",
172
- "@types/react": "19.2.15",
168
+ "@biomejs/biome": "2.5.2",
169
+ "@rsbuild/plugin-react": "^2.1.0",
170
+ "@rslib/core": "^0.23.2",
171
+ "@types/node": "^26.1.0",
172
+ "@types/react": "19.2.17",
173
173
  "@types/react-dom": "19.2.3",
174
- "react": "19.2.6",
175
- "react-dom": "19.2.6",
174
+ "react": "^19.2.7",
175
+ "react-dom": "^19.2.7",
176
176
  "rimraf": "^6.1.3",
177
- "tsx": "^4.22.3",
178
- "typescript": "6.0.3",
177
+ "tsx": "^4.23.0",
178
+ "typescript": "^7.0.2",
179
179
  "typescript-plugin-css-modules": "^5.2.0",
180
- "vitest": "^4.1.7"
180
+ "vitest": "^4.1.10"
181
181
  },
182
182
  "publishConfig": {
183
183
  "access": "public",
@@ -18,6 +18,7 @@ import type {
18
18
  TabSetDefinition,
19
19
  WorkflowStatus,
20
20
  } from '@byline/core'
21
+ import { getClientConfig } from '@byline/core'
21
22
  import type { DocumentPatch } from '@byline/core/patches'
22
23
  import { useTranslation } from '@byline/i18n/react'
23
24
  import { Alert, Button, ComboButton } from '@byline/ui/react'
@@ -241,6 +242,19 @@ const FormContent = ({
241
242
  const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale)
242
243
  const { uploadField } = useBylineFieldServices()
243
244
 
245
+ // Path-widget wiring. The live preview must use the installation's
246
+ // client-side slugifier (same function as `ServerConfig.slugifier`) so it
247
+ // agrees with the persisted path. And when the `useAsPath` source is a
248
+ // server-assigned `counter` or a read-only field, its value can't be
249
+ // reproduced or changed through the form, so the widget suppresses its
250
+ // source-derived preview and "Regenerate" affordance.
251
+ const pathSlugifier = getClientConfig().slugifier
252
+ const pathSourceLocked = useMemo(() => {
253
+ if (!useAsPath) return false
254
+ const source = fields.find((f) => f.name === useAsPath)
255
+ return source != null && (source.type === 'counter' || source.readOnly === true)
256
+ }, [useAsPath, fields])
257
+
244
258
  // Sync contentLocale when the route re-fetches with a different locale.
245
259
  useEffect(() => {
246
260
  if (initialLocale) setContentLocale(initialLocale)
@@ -672,6 +686,8 @@ const FormContent = ({
672
686
  defaultLocale={defaultLocale}
673
687
  activeLocale={contentLocale}
674
688
  mode={mode}
689
+ slugifier={pathSlugifier}
690
+ sourceLocked={pathSourceLocked}
675
691
  />
676
692
  )}
677
693
  {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
@@ -105,6 +105,9 @@ describe('PathWidget', () => {
105
105
  props: Partial<{
106
106
  useAsPath: string | undefined
107
107
  mode: 'create' | 'edit'
108
+ activeLocale: string
109
+ slugifier: (value: string, ctx: { locale: string; collectionPath: string }) => string
110
+ sourceLocked: boolean
108
111
  }> = {}
109
112
  ) => {
110
113
  act(() => {
@@ -113,7 +116,10 @@ describe('PathWidget', () => {
113
116
  useAsPath={props.useAsPath ?? 'title'}
114
117
  collectionPath="pages"
115
118
  defaultLocale="en"
119
+ activeLocale={props.activeLocale ?? 'en'}
116
120
  mode={props.mode ?? 'create'}
121
+ slugifier={props.slugifier}
122
+ sourceLocked={props.sourceLocked}
117
123
  />
118
124
  )
119
125
  })
@@ -214,4 +220,31 @@ describe('PathWidget', () => {
214
220
  render({ mode: 'edit', useAsPath: undefined })
215
221
  expect(container.querySelector('button')).toBeNull()
216
222
  })
223
+
224
+ it('derives the preview with the provided slugifier instead of the default', () => {
225
+ // A custom slugifier that zero-pads a numeric serial to 7 digits — the
226
+ // shape used when a `counter` field feeds the path.
227
+ const padSerial = (value: string) => value.padStart(7, '0')
228
+ setFixture({ systemPath: null, sourceValue: 1 })
229
+ render({ mode: 'create', slugifier: padSerial })
230
+
231
+ const input = getInput()
232
+ // Default slugify would yield "1"; the custom slugifier yields "0000001".
233
+ expect(input.getAttribute('placeholder')).toBe('Will be saved as "0000001"')
234
+ })
235
+
236
+ it('suppresses the create-mode placeholder preview when the source is locked', () => {
237
+ setFixture({ systemPath: null, sourceValue: 1 })
238
+ render({ mode: 'create', sourceLocked: true })
239
+ expect(getInput().getAttribute('placeholder')).toBeNull()
240
+ })
241
+
242
+ it('suppresses the Regenerate button when the source is locked', () => {
243
+ // Persisted path differs from what a naive re-derivation would produce,
244
+ // which would normally surface Regenerate — but a locked (server-assigned)
245
+ // source can't be regenerated from the form, so it must stay hidden.
246
+ setFixture({ systemPath: '0000001', sourceValue: 1 })
247
+ render({ mode: 'edit', sourceLocked: true })
248
+ expect(container.querySelector('button')).toBeNull()
249
+ })
217
250
  })
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { useCallback, useMemo } from 'react'
12
12
 
13
+ import type { SlugifierFn } from '@byline/core'
13
14
  import { slugify } from '@byline/core'
14
15
  import { useTranslation } from '@byline/i18n/react'
15
16
  import { Input, Label } from '@byline/ui/react'
@@ -47,6 +48,23 @@ export interface PathWidgetProps {
47
48
  activeLocale: string
48
49
  /** `'create'` shows the live derived preview as placeholder text. */
49
50
  mode: 'create' | 'edit'
51
+ /**
52
+ * Installation slugifier used to derive the live preview. Must match the
53
+ * server-side `ServerConfig.slugifier` so the preview agrees with what is
54
+ * persisted. Defaults to the built-in `slugify` when omitted — callers
55
+ * that keep the default slugifier need not pass this.
56
+ */
57
+ slugifier?: SlugifierFn
58
+ /**
59
+ * When `true`, the `useAsPath` source field's value is not editable through
60
+ * this form (e.g. it is an allocator-assigned `counter`, or an otherwise
61
+ * read-only field). Its value is either server-assigned — and so not
62
+ * reproducible client-side — or simply cannot change, which means the
63
+ * source-derived live preview and the "Regenerate" affordance are
64
+ * meaningless. When set, the widget suppresses both and just shows the
65
+ * persisted path. Defaults to `false`.
66
+ */
67
+ sourceLocked?: boolean
50
68
  }
51
69
 
52
70
  /**
@@ -71,12 +89,20 @@ export const PathWidget = ({
71
89
  defaultLocale,
72
90
  activeLocale,
73
91
  mode,
92
+ slugifier,
93
+ sourceLocked = false,
74
94
  }: PathWidgetProps) => {
75
95
  const { setSystemPath } = useFormContext()
76
96
  const { t } = useTranslation('byline-admin')
77
97
  const systemPath = useSystemPath()
78
98
  const sourceValue = useFieldValue<unknown>(useAsPath ?? '')
79
99
 
100
+ // The installation slugifier, or the built-in default. Server-side path
101
+ // derivation uses `ServerConfig.slugifier`; this must be the same function
102
+ // (registered via `ClientConfig.slugifier`) or the preview will disagree
103
+ // with what the server persists.
104
+ const runSlugify = slugifier ?? slugify
105
+
80
106
  // Phase 1: paths are written/edited only under the default content
81
107
  // locale. When editing a translation, the widget locks down — the
82
108
  // input is read-only, the Regenerate action is suppressed, and a
@@ -87,11 +113,13 @@ export const PathWidget = ({
87
113
  // field value if no override were set. Used as placeholder in create
88
114
  // mode and as the target of the "Regenerate" action.
89
115
  const livePreview = useMemo(() => {
90
- if (!useAsPath) return ''
116
+ // A locked source (server-assigned counter / read-only field) can't be
117
+ // previewed or regenerated from the form — suppress the derivation.
118
+ if (!useAsPath || sourceLocked) return ''
91
119
  const asString = coerceToString(sourceValue)
92
120
  if (asString.length === 0) return ''
93
- return slugify(asString, { locale: defaultLocale, collectionPath })
94
- }, [useAsPath, sourceValue, defaultLocale, collectionPath])
121
+ return runSlugify(asString, { locale: defaultLocale, collectionPath })
122
+ }, [useAsPath, sourceLocked, sourceValue, defaultLocale, collectionPath, runSlugify])
95
123
 
96
124
  const inputValue = systemPath ?? ''
97
125
 
@@ -115,8 +143,8 @@ export const PathWidget = ({
115
143
  // field-hook advisory behaviour).
116
144
  const formatted = useMemo(() => {
117
145
  if (inputValue.length === 0) return ''
118
- return slugify(inputValue, { locale: defaultLocale, collectionPath })
119
- }, [inputValue, defaultLocale, collectionPath])
146
+ return runSlugify(inputValue, { locale: defaultLocale, collectionPath })
147
+ }, [inputValue, defaultLocale, collectionPath, runSlugify])
120
148
 
121
149
  const validationHint =
122
150
  inputValue.length > 0 && formatted !== inputValue
@@ -97,10 +97,10 @@ export async function executeUploads(
97
97
  /**
98
98
  * Extract the leaf field name from a `fieldPath`. Top-level upload
99
99
  * fields (`'image'`, `'avatar'`) pass through unchanged; nested paths
100
- * (`'profile.avatar'`) reduce to their last segment, since the
101
- * server-side resolver matches against top-level field names today.
102
- * Nested upload fields would need a richer transport selector when
103
- * they land the host resolver is the natural place to extend.
100
+ * (`'files[0].filesGroup.publicationFile'`) reduce to their last
101
+ * segment. The server-side resolver walks the schema recursively and
102
+ * matches upload fields by leaf name at any nesting depth, so leaf
103
+ * names must be unique among a collection's upload-capable fields.
104
104
  */
105
105
  function uploadFieldName(fieldPath: string): string {
106
106
  const dot = fieldPath.lastIndexOf('.')