Files
2023-10-15 10:23:17 -04:00

512 lines
14 KiB
TypeScript

import escapeRegExp from 'lodash/escapeRegExp';
import get from 'lodash/get';
import groupBy from 'lodash/groupBy';
import { fileForEntry, selectEntrySlug } from './util/collection.util';
import set from './util/set.util';
import type {
BaseField,
Collection,
Entry,
EntryData,
Field,
I18nInfo,
I18nStructure,
ObjectValue,
ValueOrNestedValue,
i18nCollection,
} from '../interface';
import type { EntryDraftState } from '../reducers/entryDraft';
export const I18N = 'i18n';
export const I18N_STRUCTURE_MULTIPLE_FOLDERS = 'multiple_folders';
export const I18N_STRUCTURE_MULTIPLE_FILES = 'multiple_files';
export const I18N_STRUCTURE_SINGLE_FILE = 'single_file';
export const I18N_FIELD_TRANSLATE = 'translate';
export const I18N_FIELD_DUPLICATE = 'duplicate';
export const I18N_FIELD_NONE = 'none';
export function hasI18n<EF extends BaseField>(
collection: Collection<EF> | i18nCollection<EF>,
): collection is i18nCollection<EF> {
return I18N in collection;
}
export function getI18nInfo<EF extends BaseField>(collection: i18nCollection<EF>): I18nInfo;
export function getI18nInfo<EF extends BaseField>(collection: Collection<EF>): I18nInfo | null;
export function getI18nInfo<EF extends BaseField>(
collection: Collection<EF> | i18nCollection<EF>,
): I18nInfo | null {
if (!hasI18n(collection) || typeof collection[I18N] !== 'object') {
return null;
}
return collection.i18n;
}
export function getI18nFilesDepth<EF extends BaseField>(collection: Collection<EF>, depth: number) {
const { structure } = getI18nInfo(collection) as I18nInfo;
if (structure === I18N_STRUCTURE_MULTIPLE_FOLDERS) {
return depth + 1;
}
return depth;
}
export function isFieldTranslatable(field: Field, locale?: string, defaultLocale?: string) {
return locale !== defaultLocale && field.i18n === I18N_FIELD_TRANSLATE;
}
export function isFieldDuplicate(field: Field, locale?: string, defaultLocale?: string) {
return locale !== defaultLocale && field.i18n === I18N_FIELD_DUPLICATE;
}
export function isFieldHidden(field: Field, locale?: string, defaultLocale?: string) {
return locale !== defaultLocale && field.i18n === I18N_FIELD_NONE;
}
export function getLocaleDataPath(locale: string) {
return [I18N, locale, 'data'];
}
export function getDataPath(locale: string, defaultLocale: string) {
return locale !== defaultLocale ? getLocaleDataPath(locale) : ['data'];
}
export function getFilePath(
structure: I18nStructure,
extension: string,
path: string,
slug: string,
locale: string,
) {
switch (structure) {
case I18N_STRUCTURE_MULTIPLE_FOLDERS:
return path.replace(`/${slug}`, `/${locale}/${slug}`);
case I18N_STRUCTURE_MULTIPLE_FILES:
return path.replace(new RegExp(`${escapeRegExp(extension)}$`), `${locale}.${extension}`);
case I18N_STRUCTURE_SINGLE_FILE:
default:
return path;
}
}
export function getLocaleFromPath(structure: I18nStructure, extension: string, path: string) {
switch (structure) {
case I18N_STRUCTURE_MULTIPLE_FOLDERS: {
const parts = path.split('/');
// filename
parts.pop();
// locale
return parts.pop();
}
case I18N_STRUCTURE_MULTIPLE_FILES: {
const parts = path.slice(0, -`.${extension}`.length);
return parts.split('.').pop();
}
case I18N_STRUCTURE_SINGLE_FILE:
default:
return '';
}
}
export function getFilePaths<EF extends BaseField>(
collection: Collection<EF>,
extension: string,
path: string,
slug: string,
) {
const { structure, locales } = getI18nInfo(collection) as I18nInfo;
if (structure === I18N_STRUCTURE_SINGLE_FILE) {
return [path];
}
const paths = locales.map(locale =>
getFilePath(structure as I18nStructure, extension, path, slug, locale),
);
return paths;
}
export function normalizeFilePath(structure: I18nStructure, path: string, locale: string) {
switch (structure) {
case I18N_STRUCTURE_MULTIPLE_FOLDERS:
return path.replace(`${locale}/`, '');
case I18N_STRUCTURE_MULTIPLE_FILES:
return path.replace(`.${locale}`, '');
case I18N_STRUCTURE_SINGLE_FILE:
default:
return path;
}
}
export function getI18nFiles<EF extends BaseField>(
collection: Collection<EF>,
extension: string,
entryDraft: Entry,
entryToRaw: (entryDraft: Entry) => string,
path: string,
slug: string,
newPath?: string,
) {
const {
structure = I18N_STRUCTURE_SINGLE_FILE,
defaultLocale,
locales,
} = getI18nInfo(collection) as I18nInfo;
if (structure === I18N_STRUCTURE_SINGLE_FILE) {
const data = locales.reduce((map, locale) => {
const dataPath = getDataPath(locale, defaultLocale);
if (map) {
map[locale] = get(entryDraft, dataPath);
}
return map;
}, {} as EntryData);
entryDraft.data = data;
return [
{
path: getFilePath(structure, extension, path, slug, locales[0]),
slug,
raw: entryToRaw(entryDraft),
...(newPath && {
newPath: getFilePath(structure, extension, newPath, slug, locales[0]),
}),
},
];
}
const dataFiles = locales
.map(locale => {
const dataPath = getDataPath(locale, defaultLocale);
entryDraft.data = get(entryDraft, dataPath);
return {
path: getFilePath(structure, extension, path, slug, locale),
slug,
raw: entryDraft.data ? entryToRaw(entryDraft) : '',
...(newPath && {
newPath: getFilePath(structure, extension, newPath, slug, locale),
}),
};
})
.filter(dataFile => dataFile.raw);
return dataFiles;
}
export function getI18nBackup(
collection: Collection,
entry: Entry,
entryToRaw: (entry: Entry) => string,
) {
const { locales, defaultLocale } = getI18nInfo(collection) as I18nInfo;
const i18nBackup = locales
.filter(l => l !== defaultLocale)
.reduce((acc, locale) => {
const dataPath = getDataPath(locale, defaultLocale);
const data = get(entry, dataPath);
if (!data) {
return acc;
}
return {
...acc,
[locale]: {
raw: entryToRaw({
...entry,
data,
}),
},
};
}, {} as Record<string, { raw: string }>);
return i18nBackup;
}
export function formatI18nBackup(
i18nBackup: Record<string, { raw: string }>,
formatRawData: (raw: string) => Entry,
) {
const i18n = Object.entries(i18nBackup).reduce((acc, [locale, { raw }]) => {
const entry = formatRawData(raw);
return { ...acc, [locale]: { data: entry.data } };
}, {});
return i18n;
}
function mergeValues<EF extends BaseField>(
collection: Collection<EF>,
structure: I18nStructure,
defaultLocale: string,
values: { locale: string; value: Entry }[],
) {
let defaultEntry = values.find(e => e.locale === defaultLocale);
if (!defaultEntry) {
defaultEntry = values[0];
console.warn(`[StaticCMS] Could not locale entry for default locale '${defaultLocale}'`);
}
const i18n = values
.filter(e => e.locale !== defaultEntry!.locale)
.reduce((acc, { locale, value }) => {
const dataPath = getLocaleDataPath(locale);
return set(acc, dataPath.join('.'), value.data);
}, {});
const path = normalizeFilePath(structure, defaultEntry.value.path, defaultLocale);
const slug = selectEntrySlug(collection, path) as string;
const entryValue: Entry = {
...defaultEntry.value,
raw: '',
...i18n,
path,
slug,
};
return entryValue;
}
function mergeSingleFileValue(entryValue: Entry, defaultLocale: string, locales: string[]): Entry {
const data = (entryValue.data?.[defaultLocale] ?? {}) as EntryData;
const i18n = locales
.filter(l => l !== defaultLocale)
.map(l => ({ locale: l, value: entryValue.data?.[l] }))
.filter(e => e.value)
.reduce((acc, e) => {
return { ...acc, [e.locale]: { data: e.value } };
}, {});
return {
...entryValue,
data,
i18n,
raw: '',
};
}
export async function getI18nEntry<EF extends BaseField>(
collection: Collection<EF>,
extension: string,
path: string,
slug: string,
getEntryValue: (path: string) => Promise<Entry>,
) {
const {
structure = I18N_STRUCTURE_SINGLE_FILE,
locales,
defaultLocale,
} = getI18nInfo(collection) as I18nInfo;
let entryValue: Entry;
if (structure === I18N_STRUCTURE_SINGLE_FILE) {
entryValue = mergeSingleFileValue(await getEntryValue(path), defaultLocale, locales);
} else {
const entryValues = await Promise.all(
locales.map(async locale => {
const entryPath = getFilePath(structure, extension, path, slug, locale);
const value = await getEntryValue(entryPath).catch(() => null);
return { value, locale };
}),
);
const nonNullValues = entryValues.filter(e => e.value !== null) as {
value: Entry;
locale: string;
}[];
entryValue = mergeValues(collection, structure, defaultLocale, nonNullValues);
}
return entryValue;
}
export function groupEntries<EF extends BaseField>(
collection: Collection<EF>,
extension: string,
entries: Entry[],
): Entry[] {
const {
structure = I18N_STRUCTURE_SINGLE_FILE,
defaultLocale,
locales,
} = getI18nInfo(collection) as I18nInfo;
if (structure === I18N_STRUCTURE_SINGLE_FILE) {
return entries.map(e => mergeSingleFileValue(e, defaultLocale, locales));
}
const grouped = groupBy(
entries.map(e => ({
locale: getLocaleFromPath(structure, extension, e.path) as string,
value: e,
})),
({ locale, value: e }) => {
return normalizeFilePath(structure, e.path, locale);
},
);
const groupedEntries = Object.values(grouped).reduce((acc, values) => {
const entryValue = mergeValues(collection, structure, defaultLocale, values);
return [...acc, entryValue];
}, [] as Entry[]);
return groupedEntries;
}
export function getI18nDataFiles(
collection: Collection,
extension: string,
path: string,
slug: string,
diffFiles: { path: string; id: string; newFile: boolean }[],
) {
const { structure } = getI18nInfo(collection) as I18nInfo;
if (structure === I18N_STRUCTURE_SINGLE_FILE) {
return diffFiles;
}
const paths = getFilePaths(collection, extension, path, slug);
const dataFiles = paths.reduce((acc, path) => {
const dataFile = diffFiles.find(file => file.path === path);
if (dataFile) {
return [...acc, dataFile];
} else {
return [...acc, { path, id: '', newFile: false }];
}
}, [] as { path: string; id: string; newFile: boolean }[]);
return dataFiles;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function duplicateDefaultI18nFields(collection: Collection, dataFields: any) {
const { locales, defaultLocale } = getI18nInfo(collection) as I18nInfo;
const i18nFields = Object.fromEntries(
locales
.filter(locale => locale !== defaultLocale)
.map(locale => [locale, { data: dataFields }]),
);
return i18nFields;
}
export function duplicateI18nFields(
entryDraft: EntryDraftState,
field: Field,
locales: string[],
defaultLocale: string,
fieldPath: string,
) {
const value = get(entryDraft, ['entry', 'data', ...fieldPath.split('.')]);
if (field.i18n === I18N_FIELD_DUPLICATE) {
locales
.filter(l => l !== defaultLocale)
.forEach(l => {
entryDraft = set(
entryDraft,
['entry', ...getDataPath(l, defaultLocale), fieldPath].join('.'),
value,
);
});
}
if ('fields' in field && !Array.isArray(value)) {
field.fields?.forEach(field => {
entryDraft = duplicateI18nFields(
entryDraft,
field,
locales,
defaultLocale,
`${fieldPath}.${field.name}`,
);
});
}
return entryDraft;
}
function mergeI18nData(
field: Field,
defaultData: ObjectValue | undefined | null,
i18nData: Partial<ObjectValue> | undefined | null,
): ValueOrNestedValue {
if (field.widget === 'list') {
if (field.i18n === true) {
return i18nData;
}
return defaultData;
}
if (field.widget === 'object') {
const objectDefaultData = defaultData?.[field.name] ?? null;
const objectI18nData = i18nData?.[field.name] ?? null;
if (
!Array.isArray(objectDefaultData) &&
typeof objectDefaultData === 'object' &&
!(objectDefaultData instanceof Date) &&
!Array.isArray(objectI18nData) &&
typeof objectI18nData === 'object' &&
!(objectI18nData instanceof Date)
) {
for (const childField of field.fields) {
return mergeI18nData(childField, objectDefaultData, objectI18nData);
}
}
}
if (field.i18n === 'translate') {
return i18nData?.[field.name];
}
return defaultData?.[field.name];
}
export function getPreviewEntry(
collection: Collection,
entry: Entry,
locale: string | undefined,
defaultLocale: string | undefined,
) {
if (!locale || locale === defaultLocale) {
return entry;
}
let fields: Field[] = [];
const file = fileForEntry(collection, entry.slug);
if (file) {
fields = file.fields;
} else if ('fields' in collection) {
fields = collection.fields;
}
return {
...entry,
data: fields.reduce((acc, f) => {
acc[f.name] = mergeI18nData(f, entry.data, entry.i18n?.[locale]?.data);
return acc;
}, {} as ObjectValue),
};
}
export function serializeI18n(
collection: Collection,
entry: Entry,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serializeValues: (data: any) => any,
) {
const { locales, defaultLocale } = getI18nInfo(collection) as I18nInfo;
locales
.filter(locale => locale !== defaultLocale)
.forEach(locale => {
const dataPath = getLocaleDataPath(locale);
entry = set(entry, dataPath.join('.'), serializeValues(get(entry, dataPath)));
});
return entry;
}