Conversation
There was a problem hiding this comment.
Pull request overview
This pull request adds comprehensive support for multi-language entries and entry filtering to Beacon. The changes build upon PR #27 to enable locale-specific entry versioning using a filename-based convention (e.g., Entry.en-us.yaml, Entry.fr.yaml). Additionally, it introduces include/exclude filters for entries, allowing users to selectively synchronize specific content types during pull and push operations.
Changes:
- Added entries filtering configuration with include/exclude patterns similar to assets filtering
- Implemented multi-locale entry support with backward-compatible file naming conventions
- Added HTML RTE asset reference processing to handle embedded images
- Enhanced UTF-16 encoding support in YAML file reading for cross-platform compatibility
- Improved verbose flag handling to reduce noise in non-verbose mode
Reviewed changes
Copilot reviewed 53 out of 53 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| cli/src/cfg/Config.schema.yaml | Added entries filter configuration schema |
| cli/src/cfg/transform/transformSchemaConfig.ts | Added transformation logic for entries filters |
| cli/src/ui/Options.ts | Added entries.isIncluded option to UI options |
| cli/src/ui/UiOptions.ts | Implemented entries filter initialization |
| cli/src/schema/push.ts | Integrated entry filtering into push operations |
| cli/src/schema/lib/pullModules.ts | Integrated entry filtering into pull operations |
| cli/src/schema/entries/toFilesystem.ts | Implemented multi-locale entry export with backward compatibility |
| cli/src/schema/entries/toContentstack.ts | Implemented multi-locale entry import with unmodified entry handling |
| cli/src/schema/entries/lib/buildCreator.ts | Added complex multi-locale creation logic with extensive error handling |
| cli/src/schema/entries/lib/loadEntryLocales.ts | Added utility to load all locale versions of an entry |
| cli/src/schema/entries/indexAllFsEntries.ts | Refactored to support multi-locale file detection |
| cli/src/cs/entries/getEntryLocales.ts | Added API call to retrieve entry locale information |
| cli/src/cs/entries/exportEntryLocale.ts | Added API call to export specific locale version |
| cli/src/cs/entries/import.ts | Added locale parameter support |
| cli/src/cs/locales/getLocales.ts | Added API call to retrieve stack locales |
| cli/src/cs/locales/ensureLocaleExists.ts | Added utility to ensure locale exists before pushing |
| cli/src/cs/locales/addLocale.ts | Added API call to create new locales |
| cli/src/dto/entry/lib/HtmlRteReplacer.ts | Added HTML RTE asset reference processing |
| cli/src/dto/entry/beaconReplacer/lib/processHtmlRteAsset.ts | Added HTML asset beacon replacement |
| cli/src/util/escapeRegex.ts | Added regex escaping utility for safe pattern construction |
| cli/src/fs/readYaml.ts | Enhanced with UTF-16 BOM detection and decoding |
| cli/src/schema/clear.ts | Added deleteAssets flag to respect asset filters |
| cli/src/ui/command/clear.ts | Added deleteAssets option to clear command |
| cli/src/ui/option/deleteAssets.ts | Created deleteAssets CLI option |
| cli/src/schema/xfer/lib/handleUnmodified.ts | Made unmodified item logging respect verbose flag |
| cli/src/schema/assets/lib/planPush.ts | Made asset skip warnings respect verbose flag |
| cli/src/schema/assetFolders/lib/planPush.ts | Made folder skip warnings respect verbose flag |
| test/integration/workflow/lib/loadEntry.ts | Enhanced test helper to support multi-locale entries |
| doc/lessons-learned/multi-language-support.md | Added comprehensive documentation on multi-locale implementation |
| README.md | Added cookbook section for entry filtering configuration |
| build/lib/compileTypeScript.js | Improved Windows compatibility for null exit status |
| cli/build/lib/copyFile.js | Enhanced to ensure destination directory exists |
| cli/build/lib/post-tsc.js | Fixed file URL handling for chmod operation |
| @@ -59,17 +48,582 @@ export default function buildCreator( | |||
| }; | |||
| } | |||
|
|
|||
| async function loadLocaleVersions(entry: Entry, contentTypeUid: string) { | |||
| // entry.title is the base filename (extracted from actual files during indexing) | |||
| // Use it directly instead of sanitizing, since files may contain special chars | |||
| const baseFilename = entry.title; | |||
| const directory = schemaDirectory(contentTypeUid); | |||
| const fsLocaleVersions = await loadEntryLocales( | |||
| directory, | |||
| entry.title, | |||
| baseFilename, | |||
| ); | |||
|
|
|||
| if (fsLocaleVersions.length === 0) { | |||
| throw new Error(`No locale versions found for entry ${entry.title}.`); | |||
| } | |||
|
|
|||
| return fsLocaleVersions; | |||
| } | |||
|
|
|||
| async function createFirstLocale( | |||
| ctx: Ctx, | |||
| transformer: BeaconReplacer, | |||
| contentType: ContentType, | |||
| fsLocaleVersions: Awaited<ReturnType<typeof loadLocaleVersions>>, | |||
| ): Promise<Entry> { | |||
| const [firstLocale] = fsLocaleVersions; | |||
|
|
|||
| if (!firstLocale) { | |||
| throw new Error('No locale versions found'); | |||
| } | |||
|
|
|||
| const transformed = transformer.process(firstLocale.entry); | |||
|
|
|||
| // Pass undefined for 'default' locale (single-locale backward compat) | |||
| const locale = | |||
| firstLocale.locale === 'default' ? undefined : firstLocale.locale; | |||
|
|
|||
| try { | |||
| return await importEntry( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| transformed, | |||
| false, | |||
| locale, | |||
| ); | |||
| } catch (ex) { | |||
| return await handleDuplicateKeyError( | |||
| ex, | |||
| ctx, | |||
| contentType, | |||
| transformed, | |||
| locale, | |||
| ); | |||
| } | |||
| } | |||
|
|
|||
| async function createEntryWithGloballyUniqueTitle( | |||
| ctx: Ctx, | |||
| contentType: ContentType, | |||
| transformed: ReturnType<BeaconReplacer['process']>, | |||
| locale: string | undefined, | |||
| ): Promise<Entry> { | |||
| const ui = getUi(); | |||
| const uniqueTitle = `${transformed.title} (${contentType.uid})`; | |||
|
|
|||
| ui.warn( | |||
| `Title "${transformed.title}" conflicts globally. ` + | |||
| `Creating with unique title: "${uniqueTitle}"`, | |||
| ); | |||
|
|
|||
| return await importEntry( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| { ...transformed, title: uniqueTitle }, | |||
| false, | |||
| locale, | |||
| ); | |||
| } | |||
|
|
|||
| async function updateExistingEntry( | |||
| ctx: Ctx, | |||
| contentType: ContentType, | |||
| transformed: ReturnType<BeaconReplacer['process']>, | |||
| uid: string, | |||
| locale: string | undefined, | |||
| ): Promise<Entry> { | |||
| try { | |||
| return await importEntry( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| { ...transformed, uid }, | |||
| true, | |||
| locale, | |||
| ); | |||
| } catch (updateEx) { | |||
| if (isTitleNotUniqueError(updateEx)) { | |||
| const ui = getUi(); | |||
| const uniqueTitle = `${transformed.title} [${uid}]`; | |||
|
|
|||
| ui.warn( | |||
| `Title "${transformed.title}" conflicts in ${locale ?? 'default'} locale. ` + | |||
| `Using unique title: "${uniqueTitle}"`, | |||
| ); | |||
|
|
|||
| return await importEntry( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| { ...transformed, title: uniqueTitle, uid }, | |||
| true, | |||
| locale, | |||
| ); | |||
| } | |||
| throw updateEx; | |||
| } | |||
| } | |||
|
|
|||
| async function handleDuplicateKeyError( | |||
| ex: unknown, | |||
| ctx: Ctx, | |||
| contentType: ContentType, | |||
| transformed: ReturnType<BeaconReplacer['process']>, | |||
| locale: string | undefined, | |||
| ): Promise<Entry> { | |||
| if (isDuplicateKeyError(ex)) { | |||
| const uid = await getUidByTitle( | |||
| ctx.cs.client, | |||
| ctx.cs.globalFields, | |||
| contentType, | |||
| transformed.title, | |||
| ); | |||
|
|
|||
| if (!uid) { | |||
| return await createEntryWithGloballyUniqueTitle( | |||
| ctx, | |||
| contentType, | |||
| transformed, | |||
| locale, | |||
| ); | |||
| } | |||
|
|
|||
| return await updateExistingEntry( | |||
| ctx, | |||
| contentType, | |||
| transformed, | |||
| uid, | |||
| locale, | |||
| ); | |||
| } | |||
|
|
|||
| throw ex; | |||
| } | |||
|
|
|||
| async function handleDuplicateKeyInLocale( | |||
| ctx: Ctx, | |||
| contentType: ContentType, | |||
| localeTransformed: ReturnType<BeaconReplacer['process']>, | |||
| createdUid: string, | |||
| locale: string, | |||
| ): Promise<Entry | undefined> { | |||
| const ui = getUi(); | |||
| ui.warn( | |||
| `Locale ${locale} for entry ${createdUid} already exists. Updating instead.`, | |||
| ); | |||
|
|
|||
| try { | |||
| return await importEntry( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| { ...localeTransformed, uid: createdUid }, | |||
| true, // Switch to update mode | |||
| locale, | |||
| ); | |||
| } catch (updateEx) { | |||
| // If update also fails with title conflict, handle it | |||
| if (isTitleNotUniqueError(updateEx)) { | |||
| return await handleTitleNotUniqueInLocale( | |||
| ctx, | |||
| contentType, | |||
| localeTransformed, | |||
| createdUid, | |||
| locale, | |||
| ); | |||
| } | |||
| throw updateEx; | |||
| } | |||
| } | |||
|
|
|||
| async function handleLocaleImportError( | |||
| ex: unknown, | |||
| ctx: Ctx, | |||
| contentType: ContentType, | |||
| localeTransformed: ReturnType<BeaconReplacer['process']>, | |||
| createdUid: string, | |||
| locale: string, | |||
| ): Promise<Entry | undefined> { | |||
| if (isLocaleAlreadyExistsError(ex)) { | |||
| return await handleLocaleExistsError( | |||
| ctx, | |||
| contentType, | |||
| localeTransformed, | |||
| createdUid, | |||
| locale, | |||
| ); | |||
| } | |||
|
|
|||
| if (isTitleNotUniqueError(ex)) { | |||
| return await handleTitleNotUniqueInLocale( | |||
| ctx, | |||
| contentType, | |||
| localeTransformed, | |||
| createdUid, | |||
| locale, | |||
| ); | |||
| } | |||
|
|
|||
| if (isDuplicateKeyError(ex)) { | |||
| return await handleDuplicateKeyInLocale( | |||
| ctx, | |||
| contentType, | |||
| localeTransformed, | |||
| createdUid, | |||
| locale, | |||
| ); | |||
| } | |||
|
|
|||
| throw ex; | |||
| } | |||
|
|
|||
| /** | |||
| * Searches for an entry in a specific locale by title. | |||
| * This is used to find conflicting standalone entries that need to be deleted | |||
| * before creating a proper locale version. | |||
| */ | |||
| async function findEntryByTitleInLocale( | |||
| client: Client, | |||
| contentTypeUid: string, | |||
| title: string, | |||
| locale: string, | |||
| ): Promise<string | undefined> { | |||
| const result = await client.GET( | |||
| '/v3/content_types/{content_type_uid}/entries', | |||
| { | |||
| params: { | |||
| path: { content_type_uid: contentTypeUid }, | |||
| query: { | |||
| limit: 1, | |||
| locale, | |||
| query: JSON.stringify({ title }), | |||
| }, | |||
| }, | |||
| }, | |||
| ); | |||
|
|
|||
| const data = result.data as unknown as | |||
| | { entries: { uid?: string }[] } | |||
| | undefined; | |||
|
|
|||
| if (!data?.entries) { | |||
| return undefined; | |||
| } | |||
|
|
|||
| const { entries } = data; | |||
|
|
|||
| if (Array.isArray(entries) && entries.length > 0) { | |||
| return entries[0]?.uid; | |||
| } | |||
|
|
|||
| return undefined; | |||
| } | |||
|
|
|||
| async function deleteConflictAndRetry( | |||
| ctx: Ctx, | |||
| contentType: ContentType, | |||
| localeTransformed: ReturnType<BeaconReplacer['process']>, | |||
| createdUid: string, | |||
| locale: string, | |||
| conflictingUid: string, | |||
| ): Promise<Entry> { | |||
| const ui = getUi(); | |||
| ui.warn( | |||
| `Deleting standalone ${locale} entry "${localeTransformed.title}" (${conflictingUid}) ` + | |||
| `to create locale version of ${createdUid}`, | |||
| ); | |||
|
|
|||
| await deleteEntry(ctx.cs.client, contentType.uid, conflictingUid); | |||
|
|
|||
| return await importEntry( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| { ...localeTransformed, uid: createdUid }, | |||
| true, | |||
| locale, | |||
| ); | |||
| } | |||
|
|
|||
| async function importWithUniqueTitle( | |||
| ctx: Ctx, | |||
| contentType: ContentType, | |||
| localeTransformed: ReturnType<BeaconReplacer['process']>, | |||
| createdUid: string, | |||
| locale: string, | |||
| ): Promise<Entry> { | |||
| const ui = getUi(); | |||
| const uniqueTitle = `${localeTransformed.title} (${contentType.uid})`; | |||
|
|
|||
| ui.warn( | |||
| `Title "${localeTransformed.title}" conflicts globally in ${locale} locale. ` + | |||
| `Using unique title: "${uniqueTitle}"`, | |||
| ); | |||
|
|
|||
| return await importEntry( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| { ...localeTransformed, title: uniqueTitle, uid: createdUid }, | |||
| true, | |||
| locale, | |||
| ); | |||
| } | |||
|
|
|||
| async function handleLocaleExistsError( | |||
| ctx: Ctx, | |||
| contentType: ContentType, | |||
| localeTransformed: ReturnType<BeaconReplacer['process']>, | |||
| createdUid: string, | |||
| locale: string, | |||
| ): Promise<Entry> { | |||
| try { | |||
| return await importEntry( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| { ...localeTransformed, uid: createdUid }, | |||
| true, | |||
| locale, | |||
| ); | |||
| } catch (updateEx) { | |||
| if (!isTitleNotUniqueError(updateEx)) { | |||
| throw updateEx; | |||
| } | |||
|
|
|||
| const conflictingUid = await findEntryByTitleInLocale( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| localeTransformed.title, | |||
| locale, | |||
| ); | |||
|
|
|||
| if (conflictingUid && conflictingUid !== createdUid) { | |||
| return await deleteConflictAndRetry( | |||
| ctx, | |||
| contentType, | |||
| localeTransformed, | |||
| createdUid, | |||
| locale, | |||
| conflictingUid, | |||
| ); | |||
| } | |||
|
|
|||
| return await importWithUniqueTitle( | |||
| ctx, | |||
| contentType, | |||
| localeTransformed, | |||
| createdUid, | |||
| locale, | |||
| ); | |||
| } | |||
| } | |||
|
|
|||
| async function deleteConflictingEntries( | |||
| ctx: Ctx, | |||
| contentType: ContentType, | |||
| localeTransformed: ReturnType<BeaconReplacer['process']>, | |||
| createdUid: string, | |||
| locale: string, | |||
| ): Promise<string[]> { | |||
| const conflictingUid = await findEntryByTitleInLocale( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| localeTransformed.title, | |||
| locale, | |||
| ); | |||
|
|
|||
| const suffixedTitle = `${localeTransformed.title} (${contentType.uid})`; | |||
| const conflictingSuffixedUid = await findEntryByTitleInLocale( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| suffixedTitle, | |||
| locale, | |||
| ); | |||
|
|
|||
| const uidsToDelete = [conflictingUid, conflictingSuffixedUid].filter( | |||
| (uid): uid is string => Boolean(uid) && uid !== createdUid, | |||
| ); | |||
|
|
|||
| if (uidsToDelete.length > 0) { | |||
| const ui = getUi(); | |||
| for (const uid of uidsToDelete) { | |||
| ui.warn( | |||
| `Deleting standalone ${locale} entry (${uid}) to create locale version of ${createdUid}`, | |||
| ); | |||
| await deleteEntry(ctx.cs.client, contentType.uid, uid); | |||
| } | |||
| } | |||
|
|
|||
| return uidsToDelete; | |||
| } | |||
|
|
|||
| async function importWithTimestampedTitle( | |||
| ctx: Ctx, | |||
| contentType: ContentType, | |||
| localeTransformed: ReturnType<BeaconReplacer['process']>, | |||
| createdUid: string, | |||
| locale: string, | |||
| ): Promise<Entry> { | |||
| const ui = getUi(); | |||
| const timestamp = Date.now(); | |||
| const uniqueTitle = `${localeTransformed.title} [${timestamp}]`; | |||
|
|
|||
| ui.warn( | |||
| `Title "${localeTransformed.title}" conflicts in ${locale} locale. ` + | |||
| `Using unique title with timestamp: "${uniqueTitle}"`, | |||
| ); | |||
|
|
|||
| try { | |||
| return await importEntry( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| { ...localeTransformed, title: uniqueTitle, uid: createdUid }, | |||
| true, | |||
| locale, | |||
| ); | |||
| } catch (createEx) { | |||
| if (isLocaleAlreadyExistsError(createEx)) { | |||
| return await importEntry( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| { ...localeTransformed, title: uniqueTitle, uid: createdUid }, | |||
| true, | |||
| locale, | |||
| ); | |||
| } | |||
| throw createEx; | |||
| } | |||
| } | |||
|
|
|||
| async function handleTitleNotUniqueInLocale( | |||
| ctx: Ctx, | |||
| contentType: ContentType, | |||
| localeTransformed: ReturnType<BeaconReplacer['process']>, | |||
| createdUid: string, | |||
| locale: string, | |||
| ): Promise<Entry> { | |||
| const uidsDeleted = await deleteConflictingEntries( | |||
| ctx, | |||
| contentType, | |||
| localeTransformed, | |||
| createdUid, | |||
| locale, | |||
| ); | |||
|
|
|||
| if (uidsDeleted.length > 0) { | |||
| try { | |||
| return await importEntry( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| { ...localeTransformed, uid: createdUid }, | |||
| true, | |||
| locale, | |||
| ); | |||
| } catch (retryEx) { | |||
| if (!isTitleNotUniqueError(retryEx)) { | |||
| throw retryEx; | |||
| } | |||
| } | |||
| } | |||
|
|
|||
| return await importWithTimestampedTitle( | |||
| ctx, | |||
| contentType, | |||
| localeTransformed, | |||
| createdUid, | |||
| locale, | |||
| ); | |||
| } | |||
|
|
|||
| async function importLocaleVersion( | |||
| ctx: Ctx, | |||
| transformer: BeaconReplacer, | |||
| contentType: ContentType, | |||
| localeVersion: { locale: string; entry: Entry }, | |||
| createdUid: string, | |||
| ): Promise<Entry | undefined> { | |||
| if (localeVersion.locale === 'default') { | |||
| return; | |||
| } | |||
|
|
|||
| const localeTransformed = transformer.process(localeVersion.entry); | |||
|
|
|||
| // For locale versions, always use overwrite mode because we're adding/updating | |||
| // a locale on an existing entry (created in the base language). | |||
| // Contentstack requires overwrite=true when creating locale versions. | |||
| try { | |||
| return await importEntry( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| { ...localeTransformed, uid: createdUid }, | |||
| true, // Always use overwrite mode for locale versions | |||
| localeVersion.locale, | |||
| ); | |||
| } catch (ex) { | |||
| return await handleLocaleImportError( | |||
| ex, | |||
| ctx, | |||
| contentType, | |||
| localeTransformed, | |||
| createdUid, | |||
| localeVersion.locale, | |||
| ); | |||
| } | |||
| } | |||
|
|
|||
| async function importAdditionalLocales( | |||
| ctx: Ctx, | |||
| transformer: BeaconReplacer, | |||
| contentType: ContentType, | |||
| fsLocaleVersions: Awaited<ReturnType<typeof loadLocaleVersions>>, | |||
| created: Entry, | |||
| ) { | |||
| const importPromises = fsLocaleVersions | |||
| .slice(1) | |||
| .map(async (localeVersion) => | |||
| importLocaleVersion( | |||
| ctx, | |||
| transformer, | |||
| contentType, | |||
| localeVersion, | |||
| created.uid, | |||
| ), | |||
| ); | |||
|
|
|||
| await Promise.all(importPromises); | |||
| } | |||
|
|
|||
| function isLocaleAlreadyExistsError(ex: unknown): boolean { | |||
| // Error code 201: Entry already exists in locale | |||
| return ( | |||
| ex instanceof ContentstackError && | |||
| ex.code === ERROR_CODE_ENTRY_ALREADY_EXISTS | |||
| ); | |||
| } | |||
|
|
|||
| function isTitleNotUniqueError(ex: unknown): boolean { | |||
| // Error code 119: Entry import failed with "title is not unique" | |||
| if ( | |||
| !(ex instanceof ContentstackError) || | |||
| ex.code !== ERROR_CODE_TITLE_NOT_UNIQUE | |||
| ) { | |||
| return false; | |||
| } | |||
|
|
|||
| return isDeepStrictEqual(ex.details, { title: ['is not unique.'] }); | |||
| } | |||
|
|
|||
| function isDuplicateKeyError(ex: unknown) { | |||
| if (!(ex instanceof ContentstackError)) { | |||
| return false; | |||
| } | |||
|
|
|||
| const invalidDataCode = 119; | |||
| if (ex.code !== invalidDataCode) { | |||
| return false; | |||
| if (ex.code === ERROR_CODE_TITLE_NOT_UNIQUE) { | |||
| return isDeepStrictEqual(ex.details, { title: ['is not unique.'] }); | |||
| } | |||
|
|
|||
| return isDeepStrictEqual(ex.details, { title: ['is not unique.'] }); | |||
| if (ex.code === ERROR_CODE_ENTRY_ALREADY_EXISTS) { | |||
| // Code 201 means entry already exists in the specified locale | |||
| return true; | |||
| } | |||
|
|
|||
| return false; | |||
| } | |||
|
|
|||
| async function getUidByTitle( | |||
| @@ -79,16 +633,31 @@ async function getUidByTitle( | |||
| title: string, | |||
| ) { | |||
| const entries = await indexEntries(client, globalFieldsByUid, contentType); | |||
| return entries.get(title)?.uid; | |||
| } | |||
|
|
|||
| function logInvalidState(contentTypeTitle: string, entryTitle: string) { | |||
| const y = createStylus('yellowBright'); | |||
| const ui = getUi(); | |||
| const msg1 = y`While importing ${contentTypeTitle} entry ${entryTitle},`; | |||
| const msg2 = 'Contentstack reported a duplicate key error based on the'; | |||
| const msg3 = 'title, but no entry with that title was found after'; | |||
| const msg4 = 're-indexing.'; | |||
| const msg = [msg1, msg2, msg3, msg4].join(' '); | |||
| ui.warn(msg); | |||
| // Try exact title first | |||
| let uid = entries.get(title)?.uid; | |||
| if (uid) { | |||
| return uid; | |||
| } | |||
|
|
|||
| // Try trimmed title if exact match fails (handles trailing/leading spaces) | |||
| const trimmedTitle = title.trim(); | |||
| if (trimmedTitle !== title) { | |||
| uid = entries.get(trimmedTitle)?.uid; | |||
| if (uid) { | |||
| return uid; | |||
| } | |||
| } | |||
|
|
|||
| // Try with content type suffix appended (handles entries created before UIDs were added) | |||
| // Previous Beacon pushes without UIDs would create entries with title conflicts | |||
| // and append the content type UID to make them unique | |||
| const titleWithSuffix = `${title} (${contentType.uid})`; | |||
| uid = entries.get(titleWithSuffix)?.uid; | |||
| if (uid) { | |||
| return uid; | |||
| } | |||
|
|
|||
| // No match found | |||
| return undefined; | |||
| } | |||
There was a problem hiding this comment.
The buildCreator.ts file (664 lines) contains complex multi-locale creation logic with extensive error handling for title conflicts, duplicate entries, and locale version management, but lacks test coverage. This file includes critical functions like createFirstLocale, importAdditionalLocales, handleDuplicateKeyError, handleLocaleImportError, and multiple conflict resolution strategies. Add comprehensive unit tests to verify behavior for success paths, error paths, and edge cases such as: locale already exists errors, title uniqueness conflicts, duplicate key scenarios, and various Contentstack API error codes (119, 201).
| export default function escapeRegex(str: string): string { | ||
| return str.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); | ||
| } |
There was a problem hiding this comment.
The escapeRegex utility function lacks test coverage. This function is critical for safely constructing regex patterns from user-controlled filenames (which may contain special regex characters like dots, brackets, etc.). Add a test file at cli/src/util/escapeRegex.test.ts to verify that special regex characters are properly escaped.
| const result = { | ||
| ...(assets ? { assets: { isIncluded: compileFilters(assets) } } : {}), | ||
| ...(strategy ? { deletionStrategy: strategy } : {}), | ||
| ...(entries ? { entries: { isIncluded: compileFilters(entries) } } : {}), |
There was a problem hiding this comment.
The entries filtering configuration lacks test coverage. Similar to how assets filtering is tested in transformSchemaConfig.test.ts, add tests to verify that entries include/exclude patterns are correctly compiled and applied. This ensures the configuration transformation works correctly for the new entries filter feature.
| @@ -49,38 +99,142 @@ export default async function toContentstack( | |||
| throw new Error(`No matching entry found for ${title}.`); | |||
| } | |||
|
|
|||
| // Load all locale versions from filesystem | |||
| const fsLocaleVersions = await loadFsLocaleVersions( | |||
| fs, | |||
| contentType.uid, | |||
| filenamesByTitle, | |||
| ); | |||
|
|
|||
| const csLocaleSet = await getExistingLocales(ctx, contentType, cs.uid); | |||
|
|
|||
| // Only push if there are new locale versions to sync | |||
| if (!shouldSyncLocales(fsLocaleVersions, csLocaleSet)) { | |||
| continue; | |||
| } | |||
|
|
|||
| // Push all locale versions (including new locales like zh-cn) | |||
| await updateAllLocales( | |||
| ctx, | |||
| transformer, | |||
| contentType, | |||
| fsLocaleVersions, | |||
| cs.uid, | |||
| csLocaleSet, | |||
| ); | |||
|
|
|||
| const entry = { ...fs, uid: cs.uid }; | |||
| ctx.references.recordEntryForReferences(contentType.uid, entry); | |||
| } | |||
|
|
|||
| return result; | |||
| } | |||
|
|
|||
| function buildUpdateFn( | |||
| ctx: Ctx, | |||
| csEntriesByTitle: ReadonlyMap<string, Entry>, | |||
| transformer: BeaconReplacer, | |||
| contentType: ContentType, | |||
| filenamesByTitle: ReadonlyMap<Entry['uid'], string>, | |||
| ) { | |||
| return async (entry: Entry) => { | |||
| const match = csEntriesByTitle.get(entry.title); | |||
|
|
|||
| if (!match) { | |||
| throw new Error(`No matching entry found for ${entry.title}.`); | |||
| } | |||
|
|
|||
| const transformed = transformer.process(entry); | |||
|
|
|||
| const updated = await importEntry( | |||
| ctx.cs.client, | |||
| const fsLocaleVersions = await loadFsLocaleVersions( | |||
| entry, | |||
| contentType.uid, | |||
| { ...transformed, uid: match.uid }, | |||
| true, | |||
| filenamesByTitle, | |||
| ); | |||
|
|
|||
| const csLocaleSet = await getExistingLocales(ctx, contentType, match.uid); | |||
|
|
|||
| await updateAllLocales( | |||
| ctx, | |||
| transformer, | |||
| contentType, | |||
| fsLocaleVersions, | |||
| match.uid, | |||
| csLocaleSet, | |||
| ); | |||
|
|
|||
| ctx.references.recordEntryForReferences(contentType.uid, { | |||
| ...entry, | |||
| uid: updated.uid, | |||
| uid: match.uid, | |||
| }); | |||
| }; | |||
| } | |||
|
|
|||
| async function loadFsLocaleVersions( | |||
| entry: Entry, | |||
| contentTypeUid: string, | |||
| filenamesByTitle: ReadonlyMap<Entry['uid'], string>, | |||
| ) { | |||
| const filename = filenamesByTitle.get(entry.title); | |||
| if (!filename) { | |||
| throw new Error(`No filename found for entry ${entry.title}.`); | |||
| } | |||
|
|
|||
| const baseFilename = filename.replace(/\.yaml$/u, ''); | |||
| const directory = schemaDirectory(contentTypeUid); | |||
|
|
|||
| return loadEntryLocales(directory, entry.title, baseFilename); | |||
| } | |||
|
|
|||
| async function getExistingLocales( | |||
| ctx: Ctx, | |||
| contentType: ContentType, | |||
| entryUid: string, | |||
| ): Promise<Set<string>> { | |||
| try { | |||
| const csLocales = await getEntryLocales( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| entryUid, | |||
| ); | |||
| return new Set(csLocales.map((l) => l.code)); | |||
| } catch { | |||
| // If the locales endpoint fails (e.g., not supported by Contentstack instance | |||
| // or entry doesn't exist yet), return empty set to indicate no locales are known | |||
| return new Set<string>(); | |||
| } | |||
| } | |||
|
|
|||
| async function updateAllLocales( | |||
| ctx: Ctx, | |||
| transformer: BeaconReplacer, | |||
| contentType: ContentType, | |||
| fsLocaleVersions: Awaited<ReturnType<typeof loadFsLocaleVersions>>, | |||
| entryUid: string, | |||
| csLocaleSet: Set<string>, | |||
| ) { | |||
| // Ensure all required locales exist in the target stack before pushing entries | |||
| const localeEnsurePromises = fsLocaleVersions | |||
| .filter((lv) => lv.locale !== 'default') | |||
| .map(async (lv) => ensureLocaleExists(ctx.cs.client, lv.locale)); | |||
|
|
|||
| await Promise.all(localeEnsurePromises); | |||
|
|
|||
| // Import all locale versions in parallel for better performance | |||
| const importPromises = fsLocaleVersions.map(async (localeVersion) => { | |||
| const transformed = transformer.process(localeVersion.entry); | |||
|
|
|||
| // Pass undefined for 'default' locale (single-locale backward compat) | |||
| const locale = | |||
| localeVersion.locale === 'default' ? undefined : localeVersion.locale; | |||
|
|
|||
| // Always use overwrite=true for locale-specific versions since the entry exists. | |||
| // For 'default' locale (single-locale backward compat), only overwrite if entry has locales. | |||
| const overwrite = locale ? true : csLocaleSet.size > 0; | |||
|
|
|||
| return importEntry( | |||
| ctx.cs.client, | |||
| contentType.uid, | |||
| { ...transformed, uid: entryUid }, | |||
| overwrite, | |||
| locale, | |||
| ); | |||
| }); | |||
|
|
|||
| await Promise.all(importPromises); | |||
| } | |||
There was a problem hiding this comment.
The helper functions shouldSyncLocales, processUnmodifiedEntries, loadFsLocaleVersions, getExistingLocales, and updateAllLocales lack test coverage. These functions handle critical logic for multi-locale synchronization including determining when to sync locales, loading filesystem locale versions, and coordinating parallel imports. Add unit tests to verify their behavior, especially edge cases like empty locale lists, network failures, and race conditions.
| export default async function readYaml(pathLike: PathLike): Promise<unknown> { | ||
| const raw = await readFile(pathLike, 'utf-8'); | ||
| // Read as buffer first to detect encoding | ||
| const buffer = await readFile(pathLike); | ||
|
|
||
| // Check for UTF-16 LE BOM (FF FE) | ||
| let raw: string; | ||
| if (buffer[0] === UTF16_LE_BOM_BYTE1 && buffer[1] === UTF16_LE_BOM_BYTE2) { | ||
| raw = buffer.toString('utf16le'); | ||
| } | ||
| // Check for UTF-16 BE BOM (FE FF) | ||
| else if ( | ||
| buffer[0] === UTF16_BE_BOM_BYTE1 && | ||
| buffer[1] === UTF16_BE_BOM_BYTE2 | ||
| ) { | ||
| // Note: Node.js doesn't have built-in utf16be, so we swap bytes | ||
| const swapped = Buffer.alloc(buffer.length); | ||
| for (let i = 0; i < buffer.length; i += BYTES_PER_UTF16_CHAR) { | ||
| const byte1 = buffer[i + 1]; | ||
| const byte2 = buffer[i]; | ||
| if (byte1 !== undefined) swapped[i] = byte1; | ||
| if (byte2 !== undefined) swapped[i + 1] = byte2; | ||
| } | ||
| raw = swapped.toString('utf16le'); | ||
| } | ||
| // Default to UTF-8 | ||
| else { | ||
| raw = buffer.toString('utf-8'); | ||
| } | ||
|
|
||
| return parse(raw); | ||
| } |
There was a problem hiding this comment.
The UTF-16 encoding detection logic lacks test coverage. This is critical functionality that handles edge cases like different byte order marks and byte swapping for big-endian encoding. Add tests to verify correct behavior for UTF-16 LE, UTF-16 BE, and UTF-8 files, including empty buffers and odd-length buffers.
| function isValidLocaleCode(code: string): boolean { | ||
| // Locale codes: 2-3 letter language code, optionally followed by separator and 2-4 letter region code | ||
| // Pattern matches: en, en-us, en-US, fr-CA, zh_CN, etc. | ||
| return /^[a-z]{2,3}(?:[_-][a-z]{2,4})?$/iu.test(code); | ||
| } |
There was a problem hiding this comment.
The isValidLocaleCode function is duplicated in multiple locations (test/integration/workflow/lib/loadEntry.ts line 50 and cli/src/schema/entries/indexAllFsEntries.ts line 161). Consider extracting this into a shared utility function to avoid code duplication and ensure consistent locale validation across the codebase.
| // If only one locale, save without locale suffix for backward compatibility. | ||
| // When multiple locales exist, write English as base file and other locales | ||
| // with locale suffixes (e.g., Entry.yaml for English, Entry.zh-chs.yaml for Chinese) | ||
|
|
||
| // Write all locale versions in parallel for better performance | ||
| const writePromises = locales.map(async (locale) => { | ||
| const isEnglish = /^en(?:[-_]|$)/iu.test(locale.code); | ||
| // Use locale suffix for non-English locales when multiple locales exist | ||
| const useLocaleSuffix = locales.length > 1 && !isEnglish; |
There was a problem hiding this comment.
There is a discrepancy between the documentation and the implementation. The documentation (line 43) states: "If an entry has multiple locale versions, all are saved with locale suffixes". However, the code (lines 82-90) writes English locales without a suffix even when multiple locales exist. The comment on line 83 mentions "write English as base file" but this contradicts the stated documentation. Either update the documentation to accurately reflect that English is treated specially, or modify the code to add locale suffixes to all files when multiple locales exist.
Added support to allow inclusion/exclusion of entries during pull/push commands.
Dependent on PR (#27)