UNPKG

@jungvonmatt/sb-migrate

Version:

CLI tool for managing Storyblok schema and content migrations

319 lines 12.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.processMigration = exports.createRollbackFile = exports.getDeprecatedTab = exports.addOrUpdateDatasource = exports.getTranslatableComponentFields = void 0; exports.resetTab = resetTab; exports.moveToTab = moveToTab; exports.moveToDeprecated = moveToDeprecated; exports.moveToSettings = moveToSettings; exports.addOrUpdateFields = addOrUpdateFields; /* eslint-disable @typescript-eslint/no-explicit-any */ const IComponent_1 = require("../types/IComponent"); const api_1 = require("./api"); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const picocolors_1 = __importDefault(require("picocolors")); const lodash_1 = require("lodash"); let i18nComponentsFields; /** * Retrieves all translatable fields for all components. * This function caches the results in memory to avoid repeated API calls. * * @returns {Promise<Record<string, string[]>>} A record mapping component names to arrays of their translatable field names */ const getAllTranslatableComponentFields = async () => { if (!i18nComponentsFields) { const response = await api_1.components.getAll(); const entries = response.data.components.map((component) => { const compName = component.name; const compSchema = component.schema; const translatableKeys = Object.entries(compSchema || {}).flatMap(([key, item]) => { if ((0, IComponent_1.isComponentSchemaBaseItem)(item) && item.translatable) { return [key]; } return []; }); return [compName, translatableKeys]; }); i18nComponentsFields = Object.fromEntries(entries); } return i18nComponentsFields; }; /** * Retrieves the translatable fields for a specific component. * This function uses the cached results from getAllTranslatableComponentFields. * * @param {string} component - The name of the component to get translatable fields for * @returns {Promise<string[]>} An array of translatable field names for the component */ const getTranslatableComponentFields = async (component) => { const i18nFields = await getAllTranslatableComponentFields(); return i18nFields?.[component] ?? []; }; exports.getTranslatableComponentFields = getTranslatableComponentFields; /** * Creates a new datasource or updates an existing one with the provided entries. * If the datasource exists, it will be updated with the new entries. * If it doesn't exist, a new one will be created. * * @param {string | IPendingDataSource} ds - Either a string name or a datasource object * @param {Omit<IPendingDataSourceEntry, "datasource_id">[]} dsEntries - Array of entries to add to the datasource * @returns {Promise<[IDataSource, IDataSourceEntry[]]>} Tuple containing the datasource and its entries */ const addOrUpdateDatasource = async (ds, dsEntries) => { const { name, slug } = typeof ds === "string" ? { name: ds, slug: ds } : ds; let entry; if (await api_1.datasources.has(slug)) { const response = await api_1.datasources.get(slug); entry = response.data.datasource; } else { const response = await api_1.datasources.create({ slug, name, }); entry = response.data.datasource; } const entries = await api_1.datasourceEntries.getAll(entry); const checkedEntries = []; for (const dsEntry of entries.data.datasource_entries || []) { if (!dsEntries.some((e) => e.value === dsEntry.value && e.name === dsEntry.name)) { await api_1.datasourceEntries.delete(dsEntry); } else { checkedEntries.push(dsEntry); } } for (const dsEntry of dsEntries) { if (checkedEntries .filter(Boolean) .map((e) => e.value) .includes(dsEntry.value)) { continue; } const response = await api_1.datasourceEntries.create({ name: dsEntry.name, value: dsEntry.value, datasource_id: entry.id, }); checkedEntries.push(response.data.datasource); } return [entry, checkedEntries]; }; exports.addOrUpdateDatasource = addOrUpdateDatasource; /** * Type guard to check if a schema item is a tab. * * @param {IComponentSchemaItem | IComponentSchemaTab} value - The schema item to check * @returns {boolean} True if the item is a tab, false otherwise */ const isTab = (value) => value?.type === "tab"; /** * Gets a tab from a component's schema by name, or creates a new one if it doesn't exist. * * @param {IComponent} component - The component to get the tab from * @param {string} name - The name of the tab to find * @returns {[string, IComponentSchemaTab]} Tuple containing the tab key and the tab object */ const getTab = (component, name) => { const nameRegexp = new RegExp(name, "i"); for (const [key, value] of Object.entries(component.schema ?? {})) { if (isTab(value) && nameRegexp.test(value?.display_name ?? "")) { return [key, value]; } } return [`tab-${name}`, { display_name: name, type: "tab" }]; }; /** * Resets a tab in a component's schema by clearing its keys. * * @param {IComponent} component - The component containing the tab * @param {string} name - The name of the tab to reset * @returns {IComponent} A new component with the reset tab */ function resetTab(component, name) { const [key, content] = getTab(component, name); return { ...component, schema: { ...component.schema, [key]: { ...content, keys: [], }, }, }; } /** * Moves fields to a specified tab in a component's schema. * * @param {IComponent} component - The component to modify * @param {string} name - The name of the tab to move fields to * @param {string[]} keys - Array of field names to move to the tab * @param {number} [pos] - Optional position for the tab * @returns {IComponent} A new component with the updated tab */ function moveToTab(component, name, keys = [], pos) { const [key, content] = getTab(component, name); return { ...component, schema: { ...component.schema, [key]: { ...content, pos, keys: [...keys], }, }, }; } /** * Gets the deprecated tab from a component's schema, or creates one if it doesn't exist. * * @param {IComponent} component - The component to get the deprecated tab from * @returns {[string, IComponentSchemaTab]} Tuple containing the tab key and the tab object */ const getDeprecatedTab = (component) => getTab(component, "deprecated"); exports.getDeprecatedTab = getDeprecatedTab; /** * Moves fields to the deprecated tab in a component's schema. * * @param {IComponent} component - The component to modify * @param {string[]} keys - Array of field names to move to the deprecated tab * @returns {IComponent} A new component with the updated deprecated tab */ function moveToDeprecated(component, keys = []) { return moveToTab(component, "deprecated", keys); } /** * Moves fields to the settings tab in a component's schema. * * @param {IComponent} component - The component to modify * @param {string[]} keys - Array of field names to move to the settings tab * @returns {IComponent} A new component with the updated settings tab */ function moveToSettings(component, keys = []) { return moveToTab(component, "settings", keys); } /** * Adds or updates fields in a component's schema. * * @param {IComponent} component - The component to modify * @param {Record<string, IComponentSchemaItem | IComponentSchemaTab>} fields - The fields to add or update * @returns {IComponent} A new component with the updated fields */ function addOrUpdateFields(component, fields) { return { ...component, schema: { ...fields, ...component.schema, ...fields, }, }; } // Reimplementation of the 'createRollbackFile' and 'processMigration' function from storyblok-cli // https://github.com/storyblok/storyblok-cli/blob/master/src/tasks/migrations/utils.js const MIGRATIONS_ROLLBACK_DIRECTORY = `${process.cwd()}/migrations/rollback`; /** * Creates a rollback file to store the original state of stories for potential rollback operations. * The file is stored in the migrations/rollback directory with a date prefix. * * @param {any[]} rollbackData - Array containing story data for rollback * @param {string} component - Component name * @param {string} field - Field name or 'generic' for general migrations * @returns {Promise<{component: string, created: boolean}>} Object indicating success and component name * @throws {Error} If file creation fails */ const createRollbackFile = async (rollbackData, component, field) => { try { // Create directory if it doesn't exist if (!fs_1.default.existsSync(MIGRATIONS_ROLLBACK_DIRECTORY)) { fs_1.default.mkdirSync(MIGRATIONS_ROLLBACK_DIRECTORY, { recursive: true }); } // Define the rollback file path with date const date = new Date().toISOString().split("T")[0]; const fileName = `${date}_rollback_${component}_${field}.json`; const filePath = path_1.default.join(MIGRATIONS_ROLLBACK_DIRECTORY, fileName); // Remove existing file if present if (fs_1.default.existsSync(filePath)) { fs_1.default.unlinkSync(filePath); } // Write rollback data to file fs_1.default.writeFileSync(filePath, JSON.stringify(rollbackData, null, 2)); console.log(`${picocolors_1.default.green("✓")} The rollback file has been created in migrations/rollback/!`); return { component, created: true, }; } catch (error) { console.error(`${picocolors_1.default.red("✗")} Failed to create rollback file:`, error); return Promise.reject(error); } }; exports.createRollbackFile = createRollbackFile; /** * Recursively processes content to apply migrations to matching components. * This function handles nested components, blocks, and rich text content. * * @param {IStoryContent} content - Content structure from Storyblok * @param {string} component - Name of the component being processed * @param {Function} migrationFn - Migration function defined by user * @param {string} storyFullSlug - Full slug of the containing story * @returns {Promise<boolean>} True if processing completed successfully */ const processMigration = async (content = {}, component = "", migrationFn, storyFullSlug) => { // Process the component if it matches the target if (content.component === component) { try { // Apply the migration function migrationFn(content); } catch (e) { console.error(`${picocolors_1.default.red("✗")} Error processing migration for ${storyFullSlug}:`, e); } } // Recursively process nested content for (const key in content) { const value = content[key]; // Handle arrays (like nested blocks) if ((0, lodash_1.isArray)(value)) { try { await Promise.all(value.map((_item) => (0, exports.processMigration)(_item, component, migrationFn, storyFullSlug))); } catch (e) { console.error(e); } } // Handle objects with component property if ((0, lodash_1.isPlainObject)(value) && (0, lodash_1.has)(value, "component")) { try { await (0, exports.processMigration)(value, component, migrationFn, storyFullSlug); } catch (e) { console.error(e); } } // Handle rich text fields with bloks if ((0, lodash_1.isPlainObject)(value) && value.type === "doc" && value.content) { value.content .filter((item) => item.type === "blok") .forEach(async (item) => { try { await (0, exports.processMigration)(item.attrs.body, component, migrationFn, storyFullSlug); } catch (e) { console.error(e); } }); } } return true; }; exports.processMigration = processMigration; //# sourceMappingURL=storyblok.js.map