@formatjs/editor 1.1.48 → 1.3.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/LICENSE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2021 FormatJS
3
+ Copyright (c) 2023 FormatJS
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
6
 
package/README.md CHANGED
@@ -1,3 +1,189 @@
1
- # @formatjs/editor [WIP]
1
+ # Headless message editor
2
2
 
3
- We've migrated the docs to https://formatjs.io/docs/tooling/editor.
3
+ Install `@formatjs/editor` alongside React 19. The published package exports
4
+ headless APIs and TypeScript declarations; demo UI remains repository-only.
5
+
6
+ The React 19 editor exposes behavior without DOM, styles, a design system, an
7
+ IntlProvider, or network requests. Consumers own catalogs, persistence, loading
8
+ states, labels, layout, and providers. The StyleX view in `demo/demo.tsx` is an
9
+ example consumer, not part of the headless entry point.
10
+
11
+ The optional `demo/design-system/` layer provides tokens and native button, input,
12
+ textarea, badge, and panel components. Customize `tokens.stylex.ts` or supply a
13
+ StyleX theme. The demo adds responsive layout, keyboard focus, validation states,
14
+ and logical spacing for RTL. Its Vite setup uses `@stylexjs/unplugin`; consumers
15
+ using another design system need neither StyleX nor these components.
16
+
17
+ ```tsx
18
+ import {useMessageEditor} from '@formatjs/editor'
19
+
20
+ function TranslationEditor({messages, onMessageChange}) {
21
+ const editor = useMessageEditor({messages, onMessageChange})
22
+ return (
23
+ <YourTextArea
24
+ label="Translation"
25
+ value={editor.selectedMessage?.translatedMessage ?? ''}
26
+ onValueChange={editor.setTranslation}
27
+ invalid={!!editor.translation?.error}
28
+ />
29
+ )
30
+ }
31
+ ```
32
+
33
+ Use your design system's controls and localize their labels in the consumer.
34
+ `Editor` also accepts a render function as `children` with the same state.
35
+ Neither API adds elements or requires a provider.
36
+
37
+ - `messages` is controlled; `onMessageChange` receives the edited message.
38
+ Apply it to the parent catalog by ID. Invalid ICU text is preserved for editing.
39
+ - `selectMessage`, `selectedMessage`, `query`, and `setQuery` control navigation.
40
+ Search covers IDs, source text, translations, and descriptions. Filtering does
41
+ not discard selection or edits. A missing selection falls back to the first
42
+ catalog message; an empty catalog has no selection.
43
+ - `setTranslation`, `copySource`, and `clearTranslation` edit the selected message.
44
+ Copy means copying source text into the translation, not the system clipboard.
45
+ - `source` and `translation` expose `{ast, error}` parse results. The AST retains
46
+ all plural/select branches, rich-text tags, and skeletons. `Message` passes the
47
+ same result to its render function; `parseMessage` is available without React.
48
+
49
+ Message IDs must be unique within a catalog. Remount the editor when switching
50
+ catalogs if selection and search should reset. Persistence and validation policy
51
+ (such as forbidding saves with ICU errors) belong to the consumer.
52
+
53
+ This replaces the WIP Material UI app entry point. Import `Editor` or
54
+ `useMessageEditor`; mount `EditorDemo` explicitly for the example view. Standalone
55
+ entry points use React's `createRoot`. No Material UI dependency remains.
56
+
57
+ Run `bazel test //packages/editor:unit_test` for state and renderer coverage.
58
+ See [visual tests](vrt/README.md) for browser coverage and baseline updates.
59
+
60
+ ## Translation workflow
61
+
62
+ `useTranslationEditor` layers draft storage and persistence on `useMessageEditor`.
63
+ The existing `Editor`, `Message`, and `useMessageEditor` APIs stay unchanged.
64
+ It adds no DOM, styling, provider, or network dependency.
65
+
66
+ ```tsx
67
+ const workflow = useTranslationEditor({
68
+ messages, // {id, defaultMessage, description?, catalogs?, locations?, translations}
69
+ locales: ['fr', 'ru'],
70
+ defaultLocale: 'fr',
71
+ pageSize: 100,
72
+ onSave: async update => {
73
+ await persist(update) // {id, locale, translation}
74
+ setMessages(current =>
75
+ current.map(message =>
76
+ message.id === update.id
77
+ ? {
78
+ ...message,
79
+ translations: {
80
+ ...message.translations,
81
+ [update.locale]: update.translation,
82
+ },
83
+ }
84
+ : message
85
+ )
86
+ )
87
+ },
88
+ })
89
+ ```
90
+
91
+ Use `workflow.editor` for editing/search/selection, `pageMessages` for the current
92
+ page, and `setLocale`, `setCatalog`, `setStatus`, and `setPage` for navigation.
93
+ `selectedMessage` exposes catalog and source-location context. Status filters
94
+ reflect saved translations, so typing does not move a message out of the list.
95
+ Locales may load asynchronously; an absent selection falls back to the first
96
+ available locale. Pagination clamps when messages or page size change.
97
+
98
+ Drafts, reset baselines, errors, and pending saves are scoped by message ID and
99
+ locale. Switching messages, filters, or locales retains drafts. `save()` validates
100
+ the selected draft, ignores duplicate submissions for that key, and reports
101
+ failure through `saveError`. A completed save updates only its submitted key;
102
+ newer edits remain dirty. Update controlled messages after persistence succeeds.
103
+ Clean drafts adopt external changes; dirty drafts retain their text. `reset()`
104
+ restores the latest saved baseline. Remount when switching unrelated catalogs
105
+ that reuse message IDs, or when intentionally discarding all drafts.
106
+
107
+ ### Multiple locale views
108
+
109
+ Mount one workflow above your locale views. `getTranslation(id, locale)` exposes
110
+ the value, baseline, validation, save feedback, and actions for that pair, sharing
111
+ the same draft store as the selected-message API:
112
+
113
+ ```tsx
114
+ const draft = workflow.getTranslation(messageId, locale)
115
+ if (!draft) return null
116
+ return (
117
+ <YourTextArea
118
+ value={draft.value}
119
+ onValueChange={draft.setTranslation}
120
+ invalid={!!draft.validationError}
121
+ />
122
+ )
123
+ ```
124
+
125
+ Each view can call `draft.reset()` and `draft.save()` independently. Hiding or
126
+ unmounting a view preserves its draft while the owning workflow remains mounted.
127
+ Keep available locales in the workflow's `locales` option; choose which views
128
+ to display in your UI. The getter returns `undefined` for IDs or locales absent
129
+ from the current options. Drafts survive their temporary removal, including saves
130
+ that complete while a message is outside a loaded page.
131
+
132
+ ### Save results and context
133
+
134
+ `save(context?)` resolves to a discriminated result. Successful persistence returns
135
+ `{status: 'saved', value}` with the value returned by `onSave`. Failures return
136
+ `{status: 'failed', error}` and also populate `saveError`. Validation failures return
137
+ `{status: 'invalid', validationError}`. Saves that do not call persistence return
138
+ `{status: 'skipped', reason}`, where the reason is `unavailable`, `unchanged`, or
139
+ `pending`. The pending guard is scoped to a message/locale pair, so different pairs
140
+ can save concurrently.
141
+
142
+ The optional caller context and persistence result are generic types:
143
+
144
+ ```tsx
145
+ type SaveContext = {intent: 'save' | 'review'}
146
+ type Receipt = {revision: string}
147
+
148
+ const workflow = useTranslationEditor<SaveContext, Receipt>({
149
+ messages,
150
+ locales,
151
+ onSave: async (update, snapshot) => {
152
+ return persist(update, {
153
+ intent: snapshot.context?.intent ?? 'save',
154
+ previousTranslation: snapshot.baselineTranslation,
155
+ source: snapshot.source,
156
+ })
157
+ },
158
+ })
159
+
160
+ const result = await workflow.save({intent: 'review'})
161
+ if (result.status === 'saved') showReceipt(result.value.revision)
162
+ ```
163
+
164
+ `onSave` receives the submitted translation and a frozen metadata object containing
165
+ the source, baseline translation, and context. Draft state and its `save` action
166
+ are render snapshots: retaining an action for a confirmation dialog retains that
167
+ translation, source, and baseline even if selection or edits subsequently change.
168
+ Context is passed by reference, not cloned; pass immutable context values.
169
+ Persistence policy, confirmation UI, and receipt presentation remain with the
170
+ consumer.
171
+
172
+ Existing one-argument `onSave` callbacks and callers that await or ignore `save()`
173
+ continue to work. Callers that explicitly annotate `save()` as `Promise<void>`
174
+ must change that annotation to `Promise<TranslationSaveResult>` (with their result
175
+ type parameter, if needed).
176
+
177
+ `validateTranslation(source, translation)` returns `null` or a localizable error
178
+ code: `empty`, `invalid-source`, `invalid-translation`, or `structure`. It checks
179
+ arguments, tag nesting, formatting styles, select branches, plural type/offset,
180
+ and exact selectors. Locale-specific plural categories are allowed; new
181
+ categories inherit the source `other` branch's argument contract. Repeated
182
+ placeholders do not change that contract. Validation is structural, not a check
183
+ of translation quality.
184
+
185
+ The optional `TranslationEditorDemo` in `demo/workflow-demo.tsx` reuses the StyleX
186
+ `EditorView`, tokens, and controls. It includes locale/catalog/status filters,
187
+ pagination, source locations, localized validation, reset, and save feedback.
188
+ Supply an `IntlProvider` and the same StyleX Vite integration used by the demo.
189
+ It is separate from the headless entry point; consumers can use any design system.
package/index.d.ts CHANGED
@@ -1,4 +1,144 @@
1
- /// <reference types="react" />
2
- export declare function CoreApp(): JSX.Element;
3
- export default function App(): JSX.Element;
1
+ import { ReactNode } from "react";
2
+ import { MessageFormatElement } from "@formatjs/icu-messageformat-parser";
3
+ //#region packages/editor/message.d.ts
4
+ type ParsedMessage = {
5
+ ast: MessageFormatElement[];
6
+ error: null;
7
+ } | {
8
+ ast: null;
9
+ error: Error;
10
+ };
11
+ /** Retains every ICU branch and skeleton; incomplete edits are valid input. */
12
+ export declare function parseMessage(message: string): ParsedMessage;
13
+ interface MessageProps {
14
+ message: string;
15
+ children: (parsed: ParsedMessage) => ReactNode;
16
+ }
17
+ export declare function Message({ message, children }: MessageProps): ReactNode;
18
+ //#endregion
19
+ //#region packages/editor/types.d.ts
20
+ interface TranslatedMessage {
21
+ id: string;
22
+ defaultMessage: string;
23
+ translatedMessage: string;
24
+ description?: string;
25
+ }
26
+ //#endregion
27
+ //#region packages/editor/core.d.ts
28
+ interface EditorOptions {
29
+ messages: readonly TranslatedMessage[];
30
+ /** Apply the edit to consumer state; persistence stays with the consumer. */
31
+ onMessageChange: (message: TranslatedMessage) => void;
32
+ defaultSelectedId?: string;
33
+ }
34
+ interface EditorState {
35
+ messages: readonly TranslatedMessage[];
36
+ selectedMessage: TranslatedMessage | undefined;
37
+ selectMessage: (id: string) => void;
38
+ query: string;
39
+ setQuery: (query: string) => void;
40
+ source: ParsedMessage | undefined;
41
+ translation: ParsedMessage | undefined;
42
+ setTranslation: (value: string) => void;
43
+ copySource: () => void;
44
+ clearTranslation: () => void;
45
+ }
46
+ /** Controlled message data with no DOM, styling, providers, or network access. */
47
+ export declare function useMessageEditor({ messages, onMessageChange, defaultSelectedId }: EditorOptions): EditorState;
48
+ interface EditorProps extends EditorOptions {
49
+ children: (editor: EditorState) => ReactNode;
50
+ }
51
+ export declare function Editor({ children, ...options }: EditorProps): ReactNode;
52
+ //#endregion
53
+ //#region packages/editor/validation.d.ts
54
+ type TranslationValidationError = "empty" | "invalid-source" | "invalid-translation" | "structure";
55
+ /** Returns a stable error code; consumers own localized error copy. */
56
+ export declare function validateTranslation(source: string, translation: string): TranslationValidationError | null;
57
+ //#endregion
58
+ //#region packages/editor/workflow.d.ts
59
+ interface SourceLocation {
60
+ file: string;
61
+ start?: number;
62
+ end?: number;
63
+ }
64
+ interface EditorMessage {
65
+ id: string;
66
+ defaultMessage: string;
67
+ description?: string;
68
+ catalogs?: readonly string[];
69
+ locations?: readonly SourceLocation[];
70
+ translations: Readonly<Record<string, string | undefined>>;
71
+ }
72
+ interface TranslationUpdate {
73
+ id: string;
74
+ locale: string;
75
+ translation: string;
76
+ }
77
+ type MessageStatus = "all" | "translated" | "missing";
78
+ /** Render-snapshot metadata plus consumer-owned context supplied to save. */
79
+ interface TranslationSaveSnapshot<TContext = void> {
80
+ readonly source: string;
81
+ readonly baselineTranslation: string;
82
+ readonly context: TContext | undefined;
83
+ }
84
+ type TranslationSaveResult<TResult = void> = {
85
+ status: "saved";
86
+ value: TResult;
87
+ } | {
88
+ status: "failed";
89
+ error: Error;
90
+ } | {
91
+ status: "invalid";
92
+ validationError: TranslationValidationError;
93
+ } | {
94
+ status: "skipped";
95
+ reason: "unavailable" | "unchanged" | "pending";
96
+ };
97
+ interface TranslationEditorOptions<TContext = void, TResult = void> {
98
+ messages: readonly EditorMessage[];
99
+ locales: readonly string[];
100
+ onSave: (update: TranslationUpdate, snapshot: TranslationSaveSnapshot<TContext>) => TResult | Promise<TResult>;
101
+ defaultLocale?: string;
102
+ pageSize?: number;
103
+ }
104
+ /** A render snapshot and actions for one message/locale pair. */
105
+ interface TranslationDraftState<TContext = void, TResult = void> {
106
+ readonly value: string;
107
+ readonly baseline: string;
108
+ readonly validationError: TranslationValidationError | null;
109
+ readonly changed: boolean;
110
+ readonly isSaving: boolean;
111
+ readonly saveError: Error | null;
112
+ readonly saved: boolean;
113
+ setTranslation: (value: string) => void;
114
+ reset: () => void;
115
+ save: (context?: TContext) => Promise<TranslationSaveResult<TResult>>;
116
+ }
117
+ interface TranslationEditorState<TContext = void, TResult = void> {
118
+ editor: EditorState;
119
+ selectedMessage: EditorMessage | undefined;
120
+ locale: string | undefined;
121
+ setLocale: (locale: string) => void;
122
+ catalogs: readonly string[];
123
+ catalog: string;
124
+ setCatalog: (catalog: string) => void;
125
+ status: MessageStatus;
126
+ setStatus: (status: MessageStatus) => void;
127
+ page: number;
128
+ pageCount: number;
129
+ setPage: (page: number) => void;
130
+ pageMessages: EditorState["messages"];
131
+ validationError: TranslationValidationError | null;
132
+ changed: boolean;
133
+ isSaving: boolean;
134
+ saveError: Error | null;
135
+ saved: boolean;
136
+ save: (context?: TContext) => Promise<TranslationSaveResult<TResult>>;
137
+ reset: () => void;
138
+ getTranslation: (id: string, locale: string) => TranslationDraftState<TContext, TResult> | undefined;
139
+ }
140
+ /** Per-message, per-locale drafts layered on the existing headless editor. */
141
+ export declare function useTranslationEditor<TContext = void, TResult = void>({ messages, locales, onSave, defaultLocale, pageSize }: TranslationEditorOptions<TContext, TResult>): TranslationEditorState<TContext, TResult>;
142
+ //#endregion
143
+ export type { EditorMessage, EditorOptions, EditorProps, EditorState, MessageProps, MessageStatus, ParsedMessage, SourceLocation, TranslatedMessage, TranslationDraftState, TranslationEditorOptions, TranslationEditorState, TranslationSaveResult, TranslationSaveSnapshot, TranslationUpdate, TranslationValidationError };
4
144
  //# sourceMappingURL=index.d.ts.map