Broad refactoring plan
This document covers refactor opportunities across the plugin codebase outside of plugin/src/lib/api/ (which is tracked separately in refactoring.md). The focus is DRY, single responsibility, separation of concerns, and small methods.
Approach: TDD throughout. Every item requires a failing test that demonstrates the problem (or the desired post-refactor behaviour) before any code changes are made. If something is broken today, the failing test comes first.
Items
1. Extract field-traversal skeleton from buildPayloadUpdateObject, buildCrowdinJsonObject, buildCrowdinHtmlObject, and restoreOrder
File: plugin/src/lib/utilities/index.ts:420–751
Problem: All four functions contain nearly-identical group / array / blocks recursive dispatch:
if (field.type === 'group') {
// recurse into field.fields
} else if (field.type === 'array') {
// map over items, recurse into field.fields
} else if (field.type === 'blocks') {
// map over items, look up block by slug, recurse
} else {
// leaf
}
The traversal skeleton — including the block-slug lookup and the filter(isEmpty) cleanup — is copy-pasted across ~250 lines. The only variation is what each function does at the leaf and container level. Any bug in the traversal logic (e.g. a new field type) must be fixed in four places. There is even a TODO comment in the source acknowledging this ("block, array and group logic is duplicated").
Fix: Extract a single traverseFields utility that accepts a visitor object:
interface FieldTraversalVisitor<T> {
group(field: GroupField, recurse: () => T): T;
array(field: ArrayField, items: unknown[], recurse: (item: unknown, subFields: Field[]) => T): T;
blocks(field: BlocksField, items: unknown[], recurse: (item: unknown, subFields: Field[]) => T): T;
leaf(field: Field, value: unknown): T;
}
buildCrowdinJsonObject, buildCrowdinHtmlObject, buildPayloadUpdateObject, and restoreOrder become thin visitors passed to traverseFields.
TDD: Write tests for traverseFields directly before extracting. Existing tests for the four builder functions become the regression suite.
2. Consolidate identical type guard bodies in types.ts and utilities/payload.ts
Files: plugin/src/lib/types.ts:73–83, plugin/src/lib/utilities/payload.ts:3–7
Problem: Three exported functions share an identical body:
// types.ts
export const isCrowdinArticleDirectory = (val): val is CrowdinArticleDirectory =>
val !== undefined && val !== null && typeof val !== 'string';
export const isCrowdinCollectionDirectory = (val): val is CrowdinCollectionDirectory =>
val !== undefined && val !== null && typeof val !== 'string';
// utilities/payload.ts
export const isNotString = <T>(val): val is T =>
val !== undefined && val !== null && typeof val !== 'string';
isNotString is a generic version of the same check. The two typed guards in types.ts could delegate to it, or all three could delegate to a single shared primitive.
Fix: Make isCrowdinArticleDirectory and isCrowdinCollectionDirectory delegate to isNotString:
export const isCrowdinArticleDirectory = (val): val is CrowdinArticleDirectory =>
isNotString(val);
TDD: Unit tests already exist for the type guards. Add a test asserting the delegation relationship to prevent future divergence.
3. Replace duplicated hook logic in syncTranslations / syncAllTranslations
File: plugin/src/lib/fields/pluginFields.ts:156–327
Problem: The two checkbox fields syncTranslations and syncAllTranslations have structurally identical beforeChange and afterChange hooks (~70 lines each, ~140 lines total). Both hooks:
- Guard on
context.triggerAfterChange === false - Extract
draftandarticleDirectoryIdfromsiblingData - Store state in
req.context - In
afterChange, type-check context then call eitherpayload.jobs.queueorupdatePayloadTranslation
The only differences are: syncTranslations computes excludeLocales (limiting to the current locale); syncAllTranslations enqueues one job per locale.
Fix: Extract createSyncBeforeChangeHook and createSyncAfterChangeHook factory functions parameterised by the sync mode:
type SyncMode = 'current-locale' | 'all-locales';
const createSyncBeforeChangeHook = (mode: SyncMode, fieldName: string) => ...
const createSyncAfterChangeHook = (mode: SyncMode, pluginOptions: PluginOptions) => ...
TDD: Add unit tests for each hook factory (both modes) before extracting. The existing pluginFields.afterRead.spec.ts covers the afterRead hook; the beforeChange/afterChange hooks currently have no unit tests.
4. Remove unused initFunctions in plugin.ts
File: plugin/src/lib/plugin.ts:68 and plugin/src/lib/plugin.ts:331
Problem: initFunctions is declared as (() => void)[], nothing is ever pushed into it, and the forEach call at line 331 is a no-op. It's dead code that misleads readers into thinking initialisation side-effects are registered there.
Fix: Delete both the declaration and the forEach call.
TDD: The existing plugin.spec.ts unit tests provide regression coverage. Verify they still pass after removal.
5. Extract documentTabFields builder from plugin.ts
File: plugin/src/lib/plugin.ts:96–118
Problem: The inline documentTabFields construction uses any[] and lives inside the plugin closure where it is hard to test independently. The logic that decides which fields to register — based on syncedCollectionSlugs and syncedGlobalSlugs — is already tested in plugin.spec.ts but only via the full plugin output, not the field builder itself.
Fix: Extract to plugin/src/lib/fields/documentTabFields.ts:
export function buildDocumentTabFields(
syncedCollectionSlugs: string[],
syncedGlobalSlugs: string[],
): Field[]
Type the return as Field[] (not any[]).
TDD: Write focused unit tests for buildDocumentTabFields directly. The existing plugin.spec.ts tests become higher-level regression coverage.
6. Split getLocalizedFields (119 lines, five concerns)
File: plugin/src/lib/utilities/index.ts:93–212
Problem: getLocalizedFields handles five distinct concerns in a single 119-line function:
- Filtering fields by localization status
- Filtering fields by type (
json/html) - Recursing into nested fields (group, array, blocks)
- Flattening container fields (tabs via
convertTabs, collapsible viagetCollapsibleLocalizedFields, row viagetRowLocalizedFields) - Applying a caller-supplied
isLocalizedpredicate
A comment in the source already flags this: "find a better way to do this - block, array and group logic is duplicated".
Fix: Extract named private helpers, keeping getLocalizedFields as a thin orchestrator:
filterByType(fields, type)—json/html/ undefined filterflattenContainerFields(fields)— tabs / collapsible / row flatteningrecurseNestedFields(field, options)— group / array / blocks recursion
TDD: Extensive tests already exist in utilities/getLocalizedFields.spec.ts. Add tests for each extracted helper before splitting.
Completed work
(none yet — all items above are pending)
Notes on approach
- Each item must have a failing (or green-baseline) test committed before any implementation changes.
- Items 1 and 3 carry the highest risk (core translation pipeline and UI field hooks respectively) — take extra care with regression coverage before touching them.
- Items 4 and 5 are safe to tackle first: item 4 is pure deletion, item 5 is a pure extraction with no behaviour change.