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:414–745
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 (now plugin/src/lib/fields/syncTranslationHooks.ts)
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 (line numbers before the refactor)
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
Pass 1 — items 2, 4, 5
- Item 2:
isNotStringis replaced byisPopulatedRelationship, which checks for an object instead of "not a string". The old name described one of three things it ruled out, and it misreported numeric ids (unpopulated relationships on SQL adapters) as populated documents.isCrowdinArticleDirectoryandisCrowdinCollectionDirectorydelegate to it. There were no existing type guard tests;types.spec.tsandutilities/payload.spec.tsnow cover the guards, including numeric ids. - Item 4:
initFunctionsremoved.onInitstill forwards to the host config'sonInit, now covered inplugin.spec.ts. - Item 5:
buildDocumentTabFieldslives infields/documentTabFields.ts, returnsField[], and has focused tests indocumentTabFields.spec.ts.
Pass 2 — item 3
- Item 3:
createSyncBeforeChangeHook(mode, pluginOptions)andcreateSyncAfterChangeHook(mode, pluginOptions)live infields/syncTranslationHooks.ts, alongsidesyncFieldNames, which maps eachSyncModeto its checkbox name. The before hook takespluginOptionsrather than a field name because the current-locale mode needslocaleMap; the field name comes fromsyncFieldNames. Baseline tests inpluginFields.syncHooks.spec.tswere committed against the old hooks first and pass unchanged against the factories. They cover both modes, inline and job-queue loading, and thetriggerAfterChange === falseguard. - SQL numeric ids:
getSyncRequestacceptsstring | number. The before hook stores the id throughgetRelationshipId, so an unpopulated SQL relationship (42) and a populated{ id: 42 }both sync. Job input stringifies the id (type: 'text').
Pass 3 — item 6
- Item 6:
getLocalizedFieldsis now a short orchestrator over three private helpers inutilities/index.ts:filterByType,recurseNestedFieldsandflattenContainerFields. Layout fields (tabs, collapsible, row) are excluded up front instead of being carried through every step and filtered out at the end. Group, array and blocks fields are now reduced once and dropped if empty; previously each was traversed twice, once bycontainsLocalizedFieldsto decide whether to keep it and again to build the result, and that repeated at every nesting level. The oldTODOabout duplicated block, array and group logic is resolved. - Tests: the helpers are private, so instead of testing them directly,
getLocalizedFields.structure.spec.tswas committed first as a baseline against the old implementation. It pins the output shape that the existing tests only covered indirectly: top-level fields before tab, collapsible and row contents; named tabs as groups; blocks reduced toslugandfields; empty containers dropped under atypefilter; and customisLocalizedpredicates. - Known quirk, kept as-is (fixed in pass 5):
convertTabswas called without the caller'sisLocalized, so unnamed tab fields were markedlocalized: trueusing the default predicate.
Pass 4 — item 1
- Item 1:
traverseFieldsandgetBlockFieldslive inutilities/traverseFields.ts.restoreOrder,buildPayloadUpdateObject,buildCrowdinJsonObjectandbuildCrowdinHtmlObjectdispatch through the visitor. Nested walks still call the builders sogetLocalizedFieldstype/locale filtering is unchanged. - Tests:
traverseFields.spec.tswas committed first (failing). The existing builder specs and the book-demo snapshot are the regression suite.
Pass 5 — convertTabs isLocalized and nested block restore
convertTabs:flattenContainerFieldspasses the caller'sisLocalizedintoconvertTabs, so unnamed tab fields are markedlocalized: truewith the same predicate as collapsible and row flattening. Covered bygetLocalizedFields.structure.spec.tsandgetLocalizedFieldsRelocalized.spec.ts.- Nested
id/blockType:restoreOrder(used bybuildPayloadUpdateObjectwhen a sourcedocumentis passed) already restores nested blocks. That is pinned in thebuildPayloadUpdateObjectblocks tests. The unused top-level-onlyrestoreIdAndBlockTypehelper on the translations API was removed.
Pass 6 — extract Payload document walk
- Vendor-neutral field walk is the
payload-localized-fieldspackage (document/):traverseFields,getLocalizedFields(includepredicate, no Crowdinjson/htmltype),restoreOrder,findField,isLocalizedField(Payload localization only). Crowdin exclude rules (custom.crowdinSync.disable/ admin description) stay on the plugin utilities wrappers. - Crowdin builders (
buildCrowdinJsonObject,buildCrowdinHtmlObject,buildPayloadUpdateObject) stay inplugin/src/lib/utilities/and callpayload-localized-fields.getLocalizedFields({ type: 'json' | 'html' })remains as a Crowdin convenience that maps toinclude. - Tests:
document/src/*.spec.tswere committed first (failing). Existing plugin utilities specs are the regression suite.
Pass 7 — extract json/html document slice
buildJsonObject,buildHtmlObject, andbuildUpdateObjectlive inpayload-localized-fields. JSON leaves are non-richText; HTML leaves arerichTextkeyed by dotted path. Arrays/blocks are keyed byid(andblockType) so round-trip does not depend on order. Locale maps fromlocale: 'all'are skipped.- Crowdin names (
buildCrowdinJsonObject,buildCrowdinHtmlObject,buildPayloadUpdateObject) stay on the plugin as wrappers that pass the CrowdinisLocalizedFieldexclude. Rich-text HTML conversion stays in the plugin. - Tests:
document/src/documentSlice.spec.tswas committed first (failing). Existing Crowdin builder specs are the regression suite.
Pass 8 — localization adapters
SourceWriter/syncDocumentSourceandTranslationReader/applyDocumentSlicelive inpayload-localization-adapters(adapters/). They consume the json/html document slice. Crowdin afterChange andpayloadCrowdinSyncTranslationsApiimplement the writer/reader. File CRUD and Lexical/Slate HTML conversion stay Crowdin-specific. Phrase and Lokalise plugins in this monorepo will depend on the same package.- Tests:
adapters/src/syncSource.spec.tsandadapters/src/applySlice.spec.tswere committed first (failing). Existing afterChange and translations specs are the regression suite.
Notes on approach
- Each item must have a failing (or green-baseline) test committed before any implementation changes.
- Item 1 is done. The four builder specs and the book-demo snapshot are the regression suite;
traverseFields.spec.tscovers dispatch, skip, unnamed layout fields, and block slug lookup.