Skip to content

Commit 406e433

Browse files
committed
Add folder structure option where locale is root
decaporg/decap-cms#4416 / PR decaporg/decap-cms#7400 by @offscriptdev
1 parent 271492c commit 406e433

File tree

8 files changed

+218
-17
lines changed

8 files changed

+218
-17
lines changed

src/lib/services/backends/local.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,8 @@ const getAllFiles = async () => {
181181

182182
const scanningPaths = [
183183
...get(allEntryFolders)
184-
.map(({ filePathMap, folderPath }) =>
185-
filePathMap ? Object.values(filePathMap) : [folderPath],
184+
.map(({ filePathMap, folderPathMap }) =>
185+
filePathMap ? Object.values(filePathMap) : Object.values(folderPathMap ?? {}),
186186
)
187187
.flat(1),
188188
...get(allAssetFolders).map(({ internalPath }) => internalPath),

src/lib/services/config/index.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,24 @@ siteConfig.subscribe((config) => {
208208
const _allEntryFolders = [
209209
...collections
210210
.filter(({ folder, hide, divider }) => typeof folder === 'string' && !hide && !divider)
211-
.map(({ name: collectionName, folder }) => ({
212-
collectionName,
213-
folderPath: stripSlashes(/** @type {string} */ (folder)),
214-
}))
211+
.map((collection) => {
212+
const { name: collectionName, folder } = collection;
213+
const folderPath = stripSlashes(/** @type {string} */ (folder));
214+
const { i18nEnabled, structure, allLocales } = getI18nConfig(collection);
215+
const i18nRootMultiFolder = i18nEnabled && structure === 'multiple_folders_i18n_root';
216+
217+
return {
218+
collectionName,
219+
folderPath,
220+
folderPathMap: Object.fromEntries(
221+
allLocales.map((locale) => [
222+
locale,
223+
i18nRootMultiFolder ? `${locale}/${folderPath}` : folderPath,
224+
]),
225+
),
226+
};
227+
})
228+
.flat(1)
215229
.sort((a, b) => compare(a.folderPath ?? '', b.folderPath ?? '')),
216230
...collections
217231
.filter(({ files, hide, divider }) => Array.isArray(files) && !hide && !divider)

src/lib/services/contents/draft/save.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export const getEntryAssetFolderPaths = (fillSlugOptions) => {
7474
: undefined;
7575

7676
const subPathFirstPart = subPath?.match(/(?<path>.+?)(?:\/[^/]+)?$/)?.groups?.path ?? '';
77-
const isMultiFolders = structure === 'multiple_folders';
77+
const isMultiFolders = ['multiple_folders', 'multiple_folders_i18n_root'].includes(structure);
7878
const { entryRelative, internalPath, publicPath } = _assetFolder ?? get(allAssetFolders)[0];
7979

8080
if (!entryRelative) {
@@ -162,6 +162,7 @@ const createEntryPath = ({ draft, locale, slug }) => {
162162

163163
const pathOptions = {
164164
multiple_folders: `${basePath}/${locale}/${path}.${extension}`,
165+
multiple_folders_i18n_root: `${locale}/${basePath}/${path}.${extension}`,
165166
multiple_files: `${basePath}/${path}.${locale}.${extension}`,
166167
single_file: `${basePath}/${path}.${extension}`,
167168
};

src/lib/services/contents/draft/save.spec.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ describe('Test getEntryAssetFolderPaths()', () => {
2222
/** @type {I18nConfig} */
2323
const i18nMultiFolder = { ...i18nBaseConfig, structure: 'multiple_folders' };
2424
/** @type {I18nConfig} */
25+
const i18nRootMultiFolder = { ...i18nBaseConfig, structure: 'multiple_folders_i18n_root' };
26+
/** @type {I18nConfig} */
2527
const i18nMultiFile = { ...i18nBaseConfig, structure: 'multiple_files' };
2628
/** @type {I18nConfig} */
2729
const i18nSingleFile = { ...i18nBaseConfig, structure: 'single_file' };
@@ -76,6 +78,38 @@ describe('Test getEntryAssetFolderPaths()', () => {
7678
});
7779
});
7880

81+
test('simple path, multiple folders at root, entry relative', () => {
82+
/** @type {Collection} */
83+
const collection = {
84+
...collectionBase,
85+
_file: { ..._file, subPath: '{{slug}}' },
86+
_i18n: i18nRootMultiFolder,
87+
_assetFolder: relativeAssetFolder,
88+
};
89+
90+
expect(getEntryAssetFolderPaths({ collection, content: {}, currentSlug })).toEqual({
91+
internalBaseAssetFolder: 'src/content/blog',
92+
internalAssetFolder: 'src/content/blog/foo',
93+
publicAssetFolder: '../foo',
94+
});
95+
});
96+
97+
test('nested path, multiple folders at root, entry relative', () => {
98+
/** @type {Collection} */
99+
const collection = {
100+
...collectionBase,
101+
_file: { ..._file, subPath: '{{slug}}/index' },
102+
_i18n: i18nRootMultiFolder,
103+
_assetFolder: relativeAssetFolder,
104+
};
105+
106+
expect(getEntryAssetFolderPaths({ collection, content: {}, currentSlug })).toEqual({
107+
internalBaseAssetFolder: 'src/content/blog',
108+
internalAssetFolder: 'src/content/blog/foo',
109+
publicAssetFolder: '../../foo',
110+
});
111+
});
112+
79113
test('simple path, multiple files, entry relative', () => {
80114
/** @type {Collection} */
81115
const collection = {
@@ -172,6 +206,38 @@ describe('Test getEntryAssetFolderPaths()', () => {
172206
});
173207
});
174208

209+
test('simple path, multiple folders at root, entry absolute', () => {
210+
/** @type {Collection} */
211+
const collection = {
212+
...collectionBase,
213+
_file: { ..._file, subPath: '{{slug}}' },
214+
_i18n: i18nRootMultiFolder,
215+
_assetFolder: absoluteAssetFolder,
216+
};
217+
218+
expect(getEntryAssetFolderPaths({ collection, content: {}, currentSlug })).toEqual({
219+
internalBaseAssetFolder: 'static/uploads/blog',
220+
internalAssetFolder: 'static/uploads/blog',
221+
publicAssetFolder: '/uploads/blog',
222+
});
223+
});
224+
225+
test('nested path, multiple folders at root, entry absolute', () => {
226+
/** @type {Collection} */
227+
const collection = {
228+
...collectionBase,
229+
_file: { ..._file, subPath: '{{slug}}/index' },
230+
_i18n: i18nRootMultiFolder,
231+
_assetFolder: absoluteAssetFolder,
232+
};
233+
234+
expect(getEntryAssetFolderPaths({ collection, content: {}, currentSlug })).toEqual({
235+
internalBaseAssetFolder: 'static/uploads/blog',
236+
internalAssetFolder: 'static/uploads/blog',
237+
publicAssetFolder: '/uploads/blog',
238+
});
239+
});
240+
175241
test('simple path, multiple files, entry absolute', () => {
176242
/** @type {Collection} */
177243
const collection = {

src/lib/services/contents/file/index.js

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ const getEntryPathRegEx = ({ extension, format, basePath, subPath, _i18n }) => {
9292
const { i18nEnabled, structure, allLocales } = _i18n;
9393
const i18nMultiFile = i18nEnabled && structure === 'multiple_files';
9494
const i18nMultiFolder = i18nEnabled && structure === 'multiple_folders';
95+
const i18nRootMultiFolder = i18nEnabled && structure === 'multiple_folders_i18n_root';
9596

9697
/**
9798
* The path pattern in the middle, which should match the filename (without extension),
@@ -105,13 +106,19 @@ const getEntryPathRegEx = ({ extension, format, basePath, subPath, _i18n }) => {
105106

106107
const localeMatcher = `(?<locale>${allLocales.join('|')})`;
107108

108-
return new RegExp(
109-
`${basePath ? `^${escapeRegExp(basePath)}\\/` : '^'}` +
110-
`${i18nMultiFolder ? `${localeMatcher}\\/` : ''}` +
111-
`${filePathMatcher}` +
112-
`${i18nMultiFile ? `\\.${localeMatcher}` : ''}` +
113-
`\\.${detectFileExtension({ format, extension })}$`,
114-
);
109+
const pattern = [
110+
'^',
111+
i18nRootMultiFolder ? `${localeMatcher}\\/` : '',
112+
basePath ? `${escapeRegExp(basePath)}\\/` : '',
113+
i18nMultiFolder ? `${localeMatcher}\\/` : '',
114+
filePathMatcher,
115+
i18nMultiFile ? `\\.${localeMatcher}` : '',
116+
'\\.',
117+
detectFileExtension({ format, extension }),
118+
'$',
119+
].join('');
120+
121+
return new RegExp(pattern);
115122
};
116123

117124
/**

src/lib/services/contents/file/index.spec.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ describe('Test getFileConfig()', () => {
7070
canonicalSlug: { key: 'translationKey', value: '{{slug}}' },
7171
};
7272

73+
/** @type {I18nConfig} */
74+
const i18nRootMultiFolder = {
75+
i18nEnabled: true,
76+
allLocales: ['en', 'fr'],
77+
initialLocales: ['en', 'fr'],
78+
defaultLocale: 'en',
79+
structure: 'multiple_folders_i18n_root',
80+
canonicalSlug: { key: 'translationKey', value: '{{slug}}' },
81+
};
82+
7383
test('entry collection without i18n', () => {
7484
expect(
7585
getFileConfig({
@@ -462,6 +472,104 @@ describe('Test getFileConfig()', () => {
462472
});
463473
});
464474

475+
test('entry collection with multi-folder-at-root i18n', () => {
476+
expect(
477+
getFileConfig({
478+
rawCollection: {
479+
...rawFolderCollection,
480+
},
481+
_i18n: i18nRootMultiFolder,
482+
}),
483+
).toEqual({
484+
extension: 'md',
485+
format: 'frontmatter',
486+
basePath: 'content/posts',
487+
subPath: undefined,
488+
fullPathRegEx: /^(?<locale>en|fr)\/content\/posts\/(?<subPath>.+)\.md$/,
489+
fullPath: undefined,
490+
fmDelimiters: undefined,
491+
yamlQuote: false,
492+
});
493+
494+
expect(
495+
getFileConfig({
496+
rawCollection: {
497+
...rawFolderCollection,
498+
path: '{{slug}}/index',
499+
},
500+
_i18n: i18nRootMultiFolder,
501+
}),
502+
).toEqual({
503+
extension: 'md',
504+
format: 'frontmatter',
505+
basePath: 'content/posts',
506+
subPath: '{{slug}}/index',
507+
fullPathRegEx: /^(?<locale>en|fr)\/content\/posts\/(?<subPath>[^/]+\/index)\.md$/,
508+
fullPath: undefined,
509+
fmDelimiters: undefined,
510+
yamlQuote: false,
511+
});
512+
513+
expect(
514+
getFileConfig({
515+
rawCollection: {
516+
...rawFolderCollection,
517+
path: '{{slug}}/index',
518+
format: 'yaml-frontmatter',
519+
},
520+
_i18n: i18nRootMultiFolder,
521+
}),
522+
).toEqual({
523+
extension: 'md',
524+
format: 'yaml-frontmatter',
525+
basePath: 'content/posts',
526+
subPath: '{{slug}}/index',
527+
fullPathRegEx: /^(?<locale>en|fr)\/content\/posts\/(?<subPath>[^/]+\/index)\.md$/,
528+
fullPath: undefined,
529+
fmDelimiters: ['---', '---'],
530+
yamlQuote: false,
531+
});
532+
533+
expect(
534+
getFileConfig({
535+
rawCollection: {
536+
...rawFolderCollection,
537+
extension: 'yml',
538+
yaml_quote: true,
539+
},
540+
_i18n: i18nRootMultiFolder,
541+
}),
542+
).toEqual({
543+
extension: 'yml',
544+
format: 'yaml',
545+
basePath: 'content/posts',
546+
subPath: undefined,
547+
fullPathRegEx: /^(?<locale>en|fr)\/content\/posts\/(?<subPath>.+)\.yml$/,
548+
fullPath: undefined,
549+
fmDelimiters: undefined,
550+
yamlQuote: true,
551+
});
552+
553+
expect(
554+
getFileConfig({
555+
rawCollection: {
556+
...rawFolderCollection,
557+
extension: 'json',
558+
},
559+
_i18n: i18nRootMultiFolder,
560+
}),
561+
).toEqual({
562+
extension: 'json',
563+
format: 'json',
564+
basePath: 'content/posts',
565+
subPath: undefined,
566+
fullPathRegEx: /^(?<locale>en|fr)\/content\/posts\/(?<subPath>.+)\.json$/,
567+
fullPath: undefined,
568+
fmDelimiters: undefined,
569+
yamlQuote: false,
570+
});
571+
});
572+
465573
test('file collection without i18n', () => {
466574
expect(
467575
getFileConfig({

src/lib/services/contents/file/process.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ const prepareEntry = async ({ file, entries, errors }) => {
8888
const i18nSingleFile = i18nEnabled && structure === 'single_file';
8989
const i18nMultiFile = i18nEnabled && structure === 'multiple_files';
9090
const i18nMultiFolder = i18nEnabled && structure === 'multiple_folders';
91+
const i18nRootMultiFolder = i18nEnabled && structure === 'multiple_folders_i18n_root';
9192

9293
// Handle a special case: top-level list field
9394
if (hasRootListField(fields)) {
@@ -131,7 +132,7 @@ const prepareEntry = async ({ file, entries, errors }) => {
131132
let locale = undefined;
132133

133134
if (fileName) {
134-
if (i18nMultiFile || i18nMultiFolder) {
135+
if (i18nMultiFile || i18nMultiFolder || i18nRootMultiFolder) {
135136
[locale, subPath] =
136137
Object.entries(filePathMap ?? {}).find(([, locPath]) => locPath === path) ?? [];
137138
} else {
@@ -173,7 +174,7 @@ const prepareEntry = async ({ file, entries, errors }) => {
173174
);
174175
}
175176

176-
if (i18nMultiFile || i18nMultiFolder) {
177+
if (i18nMultiFile || i18nMultiFolder || i18nRootMultiFolder) {
177178
if (!locale) {
178179
return;
179180
}

src/lib/typedefs.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,8 @@
162162

163163
/**
164164
* Internationalization (i18n) file structure type.
165-
* @typedef {'single_file' | 'multiple_files' | 'multiple_folders'} I18nFileStructure
165+
* @typedef {'single_file' | 'multiple_files' | 'multiple_folders' | 'multiple_folders_i18n_root' }
166+
* I18nFileStructure
166167
*/
167168

168169
/**
@@ -239,6 +240,9 @@
239240
* @property {Record<LocaleCode, string>} [filePathMap] - File path map. The key is a locale, and
240241
* the value is the corresponding file path. File collection only.
241242
* @property {string} [folderPath] - Folder path. Entry collection only.
243+
* @property {Record<LocaleCode, string>} [folderPathMap] - Folder path map. Entry collection only.
244+
* Paths in `folderPathMap` are prefixed with a locale if the `multiple_folders_i18n_root` i18n
245+
* structure is used, while `folderPath` is a bare collection `folder` path.
242246
*/
243247

244248
/**

0 commit comments

Comments
 (0)