diff --git a/src/models.ts b/src/models.ts index 761f2c08..31358dd6 100644 --- a/src/models.ts +++ b/src/models.ts @@ -230,7 +230,6 @@ export interface MetaDataOptions { isContributorsEnabled?: boolean; vcsConnector?: VCSConnector; addSystemMeta?: boolean; - addSourcePath?: boolean; resources?: Resources; shouldAlwaysAddVCSPath?: boolean; } diff --git a/src/resolvers/md2md.ts b/src/resolvers/md2md.ts index ae63ddf6..180e0abb 100644 --- a/src/resolvers/md2md.ts +++ b/src/resolvers/md2md.ts @@ -8,22 +8,24 @@ import {ArgvService, PluginService} from '../services'; import {getVarsPerFile, logger} from '../utils'; import {PluginOptions, ResolveMd2MdOptions} from '../models'; import {PROCESSING_FINISHED} from '../constants'; -import {getContentWithUpdatedMetadata} from '../services/metadata'; import {ChangelogItem} from '@diplodoc/transform/lib/plugins/changelog/types'; +import {enrichWithFrontMatter} from '../services/metadata'; export async function resolveMd2Md(options: ResolveMd2MdOptions): Promise { const {inputPath, outputPath, metadata: metadataOptions} = options; const {input, output, changelogs: changelogsSetting} = ArgvService.getConfig(); const resolvedInputPath = resolve(input, inputPath); - const varsPreset = getVarsPerFile(inputPath); + const vars = getVarsPerFile(inputPath); - const content = await getContentWithUpdatedMetadata( - readFileSync(resolvedInputPath, 'utf8'), + const content = await enrichWithFrontMatter({ + fileContent: readFileSync(resolvedInputPath, 'utf8'), metadataOptions, - varsPreset.__system as unknown, - varsPreset.__metadata, - ); + resolvedFrontMatterVars: { + systemVars: vars.__system as unknown, + metadataVars: vars.__metadata, + }, + }); const {result, changelogs} = transformMd2Md(content, { path: resolvedInputPath, @@ -31,7 +33,7 @@ export async function resolveMd2Md(options: ResolveMd2MdOptions): Promise root: resolve(input), destRoot: resolve(output), collectOfPlugins: PluginService.getCollectOfPlugins(), - vars: varsPreset, + vars: vars, log, copyFile, }); diff --git a/src/services/authors.ts b/src/services/authors.ts index db3f9ddd..4513678d 100644 --- a/src/services/authors.ts +++ b/src/services/authors.ts @@ -1,12 +1,12 @@ -import {replaceDoubleToSingleQuotes} from '../utils'; +import {Contributor} from '../models'; import {VCSConnector} from '../vcs-connector/connector-models'; -async function updateAuthorMetadataStringByAuthorLogin( - authorLogin: string, +async function updateAuthorMetadataStringByAuthorData( + authorLogin: string | object, vcsConnector?: VCSConnector, -): Promise { +): Promise { if (!vcsConnector) { - return ''; + return null; } const user = await getAuthorDetails(vcsConnector, authorLogin); @@ -15,44 +15,41 @@ async function updateAuthorMetadataStringByAuthorLogin( return user; } - return ''; + return null; } async function updateAuthorMetadataStringByFilePath( filePath: string, vcsConnector?: VCSConnector, -): Promise { +): Promise { if (!vcsConnector) { - return ''; + return null; } const user = vcsConnector.getExternalAuthorByPath(filePath); if (user) { - const author = replaceDoubleToSingleQuotes(JSON.stringify(user)); - return author; + return user; } - return ''; + return null; } async function getAuthorDetails( vcsConnector: VCSConnector, author: string | object, -): Promise { +): Promise { if (typeof author === 'object') { - // Avoiding problems when adding to html markup - return replaceDoubleToSingleQuotes(JSON.stringify(author)); + return author as Contributor; } try { - JSON.parse(author); - return replaceDoubleToSingleQuotes(author); + return JSON.parse(author); } catch { const user = await vcsConnector.getUserByLogin(author); if (user) { - return replaceDoubleToSingleQuotes(JSON.stringify(user)); + return user; } return null; @@ -60,7 +57,7 @@ async function getAuthorDetails( } export { - updateAuthorMetadataStringByAuthorLogin, + updateAuthorMetadataStringByAuthorData as updateAuthorMetadataStringByAuthorLogin, updateAuthorMetadataStringByFilePath, getAuthorDetails, }; diff --git a/src/services/contributors.ts b/src/services/contributors.ts index ab6a0fb1..63bce89e 100644 --- a/src/services/contributors.ts +++ b/src/services/contributors.ts @@ -1,7 +1,5 @@ import {readFile} from 'fs/promises'; import {dirname, join} from 'path'; - -import {replaceDoubleToSingleQuotes} from '../utils'; import {REGEXP_INCLUDE_CONTENTS, REGEXP_INCLUDE_FILE_PATH} from '../constants'; import {Contributor, Contributors} from '../models'; import {FileContributors, VCSConnector} from '../vcs-connector/connector-models'; @@ -12,19 +10,10 @@ export interface ContributorsServiceFileData { fileContent: string; } -async function getFileContributorsMetadata( +export async function getFileContributors( fileData: ContributorsServiceFileData, vcsConnector: VCSConnector, -): Promise { - const contributors = await getFileContributorsString(fileData, vcsConnector); - - return `contributors: ${contributors}`; -} - -async function getFileContributorsString( - fileData: ContributorsServiceFileData, - vcsConnector: VCSConnector, -): Promise { +): Promise { const {resolvedFilePath, inputFolderPathLength} = fileData; const relativeFilePath = resolvedFilePath.substring(inputFolderPathLength); @@ -46,7 +35,7 @@ async function getFileContributorsString( fileContributorsWithContributorsIncludedFiles, ).map(([, contributor]) => contributor); - return replaceDoubleToSingleQuotes(JSON.stringify(contributorsArray)); + return contributorsArray; } async function getContributorsForNestedFiles( @@ -123,7 +112,7 @@ function getRelativeIncludeFilePaths( return relativeIncludeFilePaths; } -async function getFileIncludes(fileData: ContributorsServiceFileData) { +export async function getFileIncludes(fileData: ContributorsServiceFileData) { const {fileContent, inputFolderPathLength} = fileData; const results = new Set(); @@ -161,5 +150,3 @@ async function getFileIncludes(fileData: ContributorsServiceFileData) { return Array.from(results.values()); } - -export {getFileContributorsMetadata, getFileContributorsString, getFileIncludes}; diff --git a/src/services/metadata.ts b/src/services/metadata.ts deleted file mode 100644 index 4c883259..00000000 --- a/src/services/metadata.ts +++ /dev/null @@ -1,378 +0,0 @@ -import {dump, load} from 'js-yaml'; -import {VCSConnector} from '../vcs-connector/connector-models'; -import {MetaDataOptions, Metadata, PathData, Resources, VarsMetadata, YfmToc} from '../models'; -import { - getAuthorDetails, - updateAuthorMetadataStringByAuthorLogin, - updateAuthorMetadataStringByFilePath, -} from './authors'; -import { - ContributorsServiceFileData, - getFileContributorsMetadata, - getFileContributorsString, - getFileIncludes, -} from './contributors'; -import {isObject} from './utils'; -import {carriageReturn} from '../utils'; -import {REGEXP_AUTHOR, metadataBorder} from '../constants'; -import {TocService} from './index'; - -function resolveAdditionalMetadata({ - pathData, - shouldAlwaysAddVCSPath, -}: MetaDataOptions): Record { - return { - vcsPath: shouldAlwaysAddVCSPath ? pathData.pathToFile : undefined, - }; -} - -async function getContentWithUpdatedMetadata( - fileContent: string, - options: MetaDataOptions, - systemVars?: unknown, - metadataVars?: VarsMetadata, -): Promise { - const {pathData, addSystemMeta, addSourcePath, resources} = options ?? {}; - - const withUpdatedStaticMeta = getContentWithUpdatedStaticMetadata({ - fileContent, - sourcePath: pathData?.pathToFile, - addSystemMeta, - addSourcePath, - resources, - systemVars, - metadataVars, - additionalMetadata: resolveAdditionalMetadata(options), - }); - - return await getContentWithUpdatedDynamicMetadata(withUpdatedStaticMeta, options); -} - -type FileMetadata = { - [key: string]: unknown; - metadata?: Record[]; -}; - -type ParseExistingMetadataReturn = { - metadata: FileMetadata; - metadataStrippedContent: string; -}; - -function parseExistingMetadata(fileContent: string): ParseExistingMetadataReturn { - const matches = matchMetadata(fileContent); - - if (matches && matches.length > 0) { - const [, metadata, , metadataStrippedContent] = matches; - - return { - metadata: load(metadata) as FileMetadata, - metadataStrippedContent, - }; - } - - return { - metadata: {}, - metadataStrippedContent: fileContent, - }; -} - -function serializeMetadata(objectMetadata: FileMetadata) { - const dumped = dump(objectMetadata).trimEnd(); - - // This empty object check is a bit naive - // The other option would be to check if all own fields are `undefined`, - // since we exploit passing in `undefined` to remove a field quite a bit - if (dumped === '{}') { - return ''; - } - - return `${metadataBorder}${carriageReturn}${dumped}${carriageReturn}${metadataBorder}${carriageReturn}`; -} - -function getContentWithUpdatedStaticMetadata({ - fileContent, - sourcePath, - addSystemMeta, - addSourcePath, - resources, - systemVars, - additionalMetadata, - metadataVars = [], -}: { - fileContent: string; - sourcePath?: string; - addSystemMeta?: boolean; - addSourcePath?: boolean; - resources?: Resources; - systemVars?: unknown; - additionalMetadata?: Record; - metadataVars?: VarsMetadata; -}): string { - const {metadata, metadataStrippedContent} = parseExistingMetadata(fileContent); - - const mergedInnerMetadata: FileMetadata['metadata'] = [ - ...(metadata.metadata ?? []), - ...metadataVars, - ]; - - // Technically, we could use the trick of creating a property, but setting it to `undefined` - // That way js-yaml wouldn't include it in the serialized YAML - // However, that way, this would overwrite (delete) existing properties, e.g.: sourcePath - // Because of this, we spread objects to create properties if necessary - const systemVarsMetadataToSpread = - addSystemMeta && isObject(systemVars) ? {__system: JSON.stringify(systemVars)} : undefined; - const sourcePathMetadataToSpread = addSourcePath && sourcePath ? {sourcePath} : undefined; - const innerMetadataToSpread = - mergedInnerMetadata.length > 0 ? {metadata: mergedInnerMetadata} : undefined; - - const mergedMetadata: FileMetadata = { - ...metadata, - ...resources, - ...additionalMetadata, - ...systemVarsMetadataToSpread, - ...sourcePathMetadataToSpread, - ...innerMetadataToSpread, - }; - - return `${serializeMetadata(mergedMetadata)}${metadataStrippedContent}`; -} - -async function getContentWithUpdatedDynamicMetadata( - fileContent: string, - options?: MetaDataOptions, -): Promise { - if (!options || !options?.isContributorsEnabled) { - return fileContent; - } - - let fileMetadata: string | undefined, fileMainContent: string | undefined; - const matches = matchMetadata(fileContent); - if (matches && matches.length > 0) { - const [, matchedFileMetadata, , matchedFileMainContent] = matches; - fileMetadata = matchedFileMetadata; - fileMainContent = matchedFileMainContent; - } - - const newMetadatas: string[] = []; - - const {isContributorsEnabled} = options; - - if (isContributorsEnabled) { - const contributorsMetaData = await getContributorsMetadataString(options, fileContent); - if (contributorsMetaData) { - newMetadatas.push(contributorsMetaData); - } - - const mtimeMetadata = await getModifiedTimeMetadataString(options, fileContent); - if (mtimeMetadata) { - newMetadatas.push(mtimeMetadata); - } - - let authorMetadata = ''; - if (fileMetadata) { - const matchAuthor = fileMetadata.match(REGEXP_AUTHOR); - if (matchAuthor) { - const matchedAuthor = matchAuthor[0]; - authorMetadata = await updateAuthorMetadataStringByAuthorLogin( - matchedAuthor, - options.vcsConnector, - ); - } - } - - if (!authorMetadata) { - const {pathToFile: relativeFilePath} = options.pathData; - authorMetadata = await updateAuthorMetadataStringByFilePath( - relativeFilePath, - options.vcsConnector, - ); - } - - if (authorMetadata) { - newMetadatas.push(`author: ${authorMetadata}`); - } - } - - if (fileMetadata && fileMainContent) { - let updatedFileMetadata = fileMetadata; - const matchAuthor = fileMetadata.match(REGEXP_AUTHOR); - - const isNewMetadataIncludesAuthor = newMetadatas.some((item) => /^author: /.test(item)); - if (matchAuthor && isNewMetadataIncludesAuthor) { - updatedFileMetadata = updatedFileMetadata.replace(`author: ${matchAuthor[0]}`, ''); - } - - return `${getUpdatedMetadataString(newMetadatas, updatedFileMetadata)}${fileMainContent}`; - } - - return `${getUpdatedMetadataString(newMetadatas)}${fileContent}`; -} - -function matchMetadata(fileContent: string) { - if (!fileContent.startsWith('---')) { - return null; - } - - // Search by format: - // --- - // metaName1: metaValue1 - // metaName2: meta value2 - // incorrectMetadata - // --- - const regexpMetadata = '(?<=-{3}\\r?\\n)((.*\\r?\\n)*?)(?=-{3}\\r?\\n)'; - // Search by format: - // --- - // main content 123 - const regexpFileContent = '-{3}\\r?\\n((.*[\r?\n]*)*)'; - - const regexpParseFileContent = new RegExp(`${regexpMetadata}${regexpFileContent}`, 'gm'); - - return regexpParseFileContent.exec(fileContent); -} - -function getFileDataForContributorsService( - pathData: PathData, - fileContent: string, -): ContributorsServiceFileData { - return { - fileContent, - resolvedFilePath: pathData.resolvedPathToFile, - inputFolderPathLength: pathData.inputFolderPath.length, - }; -} - -async function getContributorsMetadataString( - options: MetaDataOptions, - fileContent: string, -): Promise { - const {isContributorsEnabled, vcsConnector, pathData} = options; - - if (isContributorsEnabled && vcsConnector) { - return getFileContributorsMetadata( - getFileDataForContributorsService(pathData, fileContent), - vcsConnector, - ); - } - - return undefined; -} - -async function getModifiedTimeMetadataString(options: MetaDataOptions, fileContent: string) { - const {isContributorsEnabled, vcsConnector, pathData} = options; - - const {pathToFile: relativeFilePath} = pathData; - - if (!isContributorsEnabled || !vcsConnector) { - return undefined; - } - - const includedFiles = await getFileIncludes( - getFileDataForContributorsService(pathData, fileContent), - ); - includedFiles.push(relativeFilePath); - - const tocCopyFileMap = TocService.getCopyFileMap(); - - const mtimeList = includedFiles - .map((path) => { - const mappedPath = tocCopyFileMap.get(path) || path; - return vcsConnector.getModifiedTimeByPath(mappedPath); - }) - .filter((v) => typeof v === 'number') as number[]; - - if (mtimeList.length) { - const mtime = Math.max(...mtimeList); - return `updatedAt: ${new Date(mtime * 1000).toISOString()}`; - } - - return undefined; -} - -function getUpdatedMetadataString(newMetadatas: string[], defaultMetadata = ''): string { - const newMetadata = - newMetadatas.join(carriageReturn) + (newMetadatas.length ? carriageReturn : ''); - const preparedDefaultMetadata = defaultMetadata.trimRight(); - const defaultMetadataСarriage = preparedDefaultMetadata ? carriageReturn : ''; - const updatedMetadata = `${preparedDefaultMetadata}${defaultMetadataСarriage}${newMetadata}`; - - return `${metadataBorder}${carriageReturn}${updatedMetadata}${metadataBorder}${ - defaultMetadata.length ? '' : carriageReturn - }`; -} - -async function getVCSMetadata( - options: MetaDataOptions, - fileContent: string, - meta?: Metadata, -): Promise { - const {vcsConnector} = options; - - const newMetadata: Metadata = { - contributors: await getContributorsMetadata(options, fileContent), - }; - - if (!meta) { - return newMetadata; - } - - const updatedAuthor = await getAuthorMetadata(meta as Metadata, vcsConnector); - - return { - ...meta, - ...newMetadata, - author: updatedAuthor, - }; -} - -async function getContributorsMetadata( - options: MetaDataOptions, - fileContent: string, -): Promise { - const {isContributorsEnabled, vcsConnector, pathData} = options; - - if (isContributorsEnabled && vcsConnector) { - return getFileContributorsString( - getFileDataForContributorsService(pathData, fileContent), - vcsConnector, - ); - } - - return JSON.stringify([]); -} - -async function getAuthorMetadata( - meta: Metadata, - vcsConnector?: VCSConnector, -): Promise { - if (meta.author && vcsConnector) { - const updatedAuthor = await getAuthorDetails(vcsConnector, meta.author); - - return updatedAuthor; - } - - return null; -} - -function getAssetsPublicPath(filePath: string) { - const toc: YfmToc | null = TocService.getForPath(filePath) || null; - - const deepBase = toc?.root?.deepBase || toc?.deepBase || 0; - const deepBasePath = deepBase > 0 ? Array(deepBase).fill('../').join('') : './'; - - /* Relative path from folder of .md file to root of user' output folder */ - return deepBasePath; -} - -function getAssetsRootPath(filePath: string) { - const toc: YfmToc | null = TocService.getForPath(filePath) || null; - - return toc?.root?.base || toc?.base; -} - -export { - getContentWithUpdatedMetadata, - getContentWithUpdatedStaticMetadata, - getVCSMetadata, - getAssetsPublicPath, - getAssetsRootPath, -}; diff --git a/src/services/metadata/addSourcePath.ts b/src/services/metadata/addSourcePath.ts new file mode 100644 index 00000000..f54155bc --- /dev/null +++ b/src/services/metadata/addSourcePath.ts @@ -0,0 +1,11 @@ +import {parseExistingMetadata} from './parse'; +import {emplaceMetadata} from './utils'; + +export const addSourcePath = (fileContent: string, sourcePath: string) => { + const {metadata, metadataStrippedContent} = parseExistingMetadata(fileContent); + + return emplaceMetadata(metadataStrippedContent, { + ...metadata, + sourcePath, + }); +}; diff --git a/src/services/metadata/assetPaths.ts b/src/services/metadata/assetPaths.ts new file mode 100644 index 00000000..2bf607c2 --- /dev/null +++ b/src/services/metadata/assetPaths.ts @@ -0,0 +1,18 @@ +import {TocService} from '..'; +import {YfmToc} from '../../models'; + +export const getAssetsPublicPath = (filePath: string) => { + const toc: YfmToc | null = TocService.getForPath(filePath) || null; + + const deepBase = toc?.root?.deepBase || toc?.deepBase || 0; + const deepBasePath = deepBase > 0 ? Array(deepBase).fill('../').join('') : './'; + + /* Relative path from folder of .md file to root of user' output folder */ + return deepBasePath; +}; + +export const getAssetsRootPath = (filePath: string) => { + const toc: YfmToc | null = TocService.getForPath(filePath) || null; + + return toc?.root?.base || toc?.base; +}; diff --git a/src/services/metadata/enrich.ts b/src/services/metadata/enrich.ts new file mode 100644 index 00000000..52f82583 --- /dev/null +++ b/src/services/metadata/enrich.ts @@ -0,0 +1,53 @@ +import {MetaDataOptions, VarsMetadata} from '../../models'; +import {mergeMetadata} from './mergeMetadata'; +import {FileMetadata, parseExistingMetadata} from './parse'; +import {emplaceMetadata} from './utils'; +import {resolveVCSFrontMatter} from './vcsMetadata'; + +type FrontMatterVars = { + metadataVars?: VarsMetadata; + systemVars?: unknown; +}; + +type EnrichWithFrontMatterOptions = { + fileContent: string; + metadataOptions: MetaDataOptions; + resolvedFrontMatterVars: FrontMatterVars; +}; + +const resolveVCSPath = (metadata: FileMetadata, relativeInputPath: string) => { + const maybePreProcessedSourcePath = metadata.sourcePath; + + return typeof maybePreProcessedSourcePath === 'string' && maybePreProcessedSourcePath.length > 0 + ? maybePreProcessedSourcePath + : relativeInputPath; +}; + +export const enrichWithFrontMatter = async ({ + fileContent, + metadataOptions, + resolvedFrontMatterVars, +}: EnrichWithFrontMatterOptions) => { + const {systemVars, metadataVars} = resolvedFrontMatterVars; + const {resources, addSystemMeta, shouldAlwaysAddVCSPath, pathData} = metadataOptions; + const {metadata, metadataStrippedContent} = parseExistingMetadata(fileContent); + + const vcsFrontMatter = metadataOptions.isContributorsEnabled + ? await resolveVCSFrontMatter(metadata, metadataOptions, fileContent) + : undefined; + + const mergedMetadata = mergeMetadata({ + existingMetadata: metadata, + resources, + metadataVars, + systemVars: addSystemMeta ? systemVars : undefined, + additionalMetadata: { + vcsPath: shouldAlwaysAddVCSPath + ? resolveVCSPath(metadata, pathData.pathToFile) + : undefined, + ...vcsFrontMatter, + }, + }); + + return emplaceMetadata(metadataStrippedContent, mergedMetadata); +}; diff --git a/src/services/metadata/index.ts b/src/services/metadata/index.ts new file mode 100644 index 00000000..16545201 --- /dev/null +++ b/src/services/metadata/index.ts @@ -0,0 +1,4 @@ +export * from './addSourcePath'; +export * from './assetPaths'; +export * from './enrich'; +export * from './vcsMetadata'; diff --git a/src/services/metadata/mergeMetadata.ts b/src/services/metadata/mergeMetadata.ts new file mode 100644 index 00000000..d0f52795 --- /dev/null +++ b/src/services/metadata/mergeMetadata.ts @@ -0,0 +1,42 @@ +import {Resources, VarsMetadata} from '../../models'; +import {isObject} from '../utils'; +import {FileMetadata} from './parse'; + +export const mergeMetadata = ({ + existingMetadata, + resources, + systemVars, + metadataVars = [], + additionalMetadata, +}: { + existingMetadata: FileMetadata; + metadataVars?: VarsMetadata; + resources?: Resources; + systemVars?: unknown; + additionalMetadata?: Record; +}): FileMetadata => { + const mergedInnerMetadata: FileMetadata['metadata'] = [ + ...(existingMetadata.metadata ?? []), + ...metadataVars, + ]; + + // Technically, we could use the trick of creating a property, but setting it to `undefined` + // That way js-yaml wouldn't include it in the serialized YAML + // However, that way, this would overwrite (delete) existing properties, e.g.: sourcePath + // Because of this, we spread objects to create properties if necessary + const systemVarsMetadataToSpread = isObject(systemVars) + ? {__system: JSON.stringify(systemVars)} + : undefined; + const innerMetadataToSpread = + mergedInnerMetadata.length > 0 ? {metadata: mergedInnerMetadata} : undefined; + + const mergedMetadata: FileMetadata = { + ...existingMetadata, + ...resources, + ...systemVarsMetadataToSpread, + ...additionalMetadata, + ...innerMetadataToSpread, + }; + + return mergedMetadata; +}; diff --git a/src/services/metadata/parse.ts b/src/services/metadata/parse.ts new file mode 100644 index 00000000..390d8aa9 --- /dev/null +++ b/src/services/metadata/parse.ts @@ -0,0 +1,84 @@ +import {dump, load} from 'js-yaml'; +import {metadataBorder} from '../../constants'; + +export type FileMetadata = { + [key: string]: unknown; + metadata?: Record[]; +}; + +type ParseExistingMetadataReturn = { + metadata: FileMetadata; + metadataStrippedContent: string; +}; + +const matchMetadata = (fileContent: string) => { + if (!fileContent.startsWith('---')) { + return null; + } + + // Search by format: + // --- + // metaName1: metaValue1 + // metaName2: meta value2 + // incorrectMetadata + // --- + const regexpMetadata = '(?<=-{3}\\r?\\n)((.*\\r?\\n)*?)(?=-{3}\\r?\\n)'; + // Search by format: + // --- + // main content 123 + const regexpFileContent = '-{3}\\r?\\n((.*[\r?\n]*)*)'; + + const regexpParseFileContent = new RegExp(`${regexpMetadata}${regexpFileContent}`, 'gm'); + + return regexpParseFileContent.exec(fileContent); +}; + +/** + * Temporary workaround to enable parsing YAML metadata from potentially + * Liquid-aware source files + * @param content Input string which could contain Liquid-style substitution syntax (which clashes with YAML + * object syntax) + * @returns String with `{}` escaped, ready to be parsed with `js-yaml` + */ +const escapeLiquidSubstitutionSyntax = (content: string): string => + content.replace(/{{/g, '\\{\\{').replace(/}}/g, '\\}\\}'); + +/** + * Inverse of a workaround defined above. + * @see `escapeLiquidSubstitutionSyntax` + * @param escapedContent Input string with `{}` escaped with backslashes + * @returns Unescaped string + */ +const unescapeLiquidSubstitutionSyntax = (escapedContent: string): string => + escapedContent.replace(/\\{\\{/g, '{{').replace(/\\}\\}/g, '}}'); + +export const parseExistingMetadata = (fileContent: string): ParseExistingMetadataReturn => { + const matches = matchMetadata(fileContent); + + if (matches && matches.length > 0) { + const [, metadata, , metadataStrippedContent] = matches; + + return { + metadata: load(escapeLiquidSubstitutionSyntax(metadata)) as FileMetadata, + metadataStrippedContent, + }; + } + + return { + metadata: {}, + metadataStrippedContent: fileContent, + }; +}; + +export const serializeMetadata = (objectMetadata: FileMetadata) => { + const dumped = unescapeLiquidSubstitutionSyntax(dump(objectMetadata).trimEnd()); + + // This empty object check is a bit naive + // The other option would be to check if all own fields are `undefined`, + // since we exploit passing in `undefined` to remove a field quite a bit + if (dumped === '{}') { + return ''; + } + + return `${metadataBorder}\n${dumped}\n${metadataBorder}\n`; +}; diff --git a/src/services/metadata/utils.ts b/src/services/metadata/utils.ts new file mode 100644 index 00000000..bae2d7ae --- /dev/null +++ b/src/services/metadata/utils.ts @@ -0,0 +1,9 @@ +import {carriageReturn} from '../../utils'; +import {FileMetadata, serializeMetadata} from './parse'; + +// IMO, we should just always apply this at the end of the whole processing pipeline, +// not when dumping meta/front matter +const normalizeLineEndings = (input: string): string => input.replace(/\r?\n/g, carriageReturn); + +export const emplaceMetadata = (metadataStrippedContent: string, metadata: FileMetadata) => + normalizeLineEndings(`${serializeMetadata(metadata)}${metadataStrippedContent}`); diff --git a/src/services/metadata/vcsMetadata.ts b/src/services/metadata/vcsMetadata.ts new file mode 100644 index 00000000..8c123310 --- /dev/null +++ b/src/services/metadata/vcsMetadata.ts @@ -0,0 +1,135 @@ +import {TocService} from '..'; +import {Contributor, MetaDataOptions, Metadata, PathData} from '../../models'; +import {VCSConnector} from '../../vcs-connector/connector-models'; +import { + getAuthorDetails, + updateAuthorMetadataStringByAuthorLogin, + updateAuthorMetadataStringByFilePath, +} from '../authors'; +import {ContributorsServiceFileData, getFileContributors, getFileIncludes} from '../contributors'; +import {isObject} from '../utils'; +import {FileMetadata} from './parse'; + +const getFileDataForContributorsService = ( + pathData: PathData, + fileContent: string, +): ContributorsServiceFileData => { + return { + fileContent, + resolvedFilePath: pathData.resolvedPathToFile, + inputFolderPathLength: pathData.inputFolderPath.length, + }; +}; + +const getModifiedTimeISOString = async (options: MetaDataOptions, fileContent: string) => { + const {isContributorsEnabled, vcsConnector, pathData} = options; + + const {pathToFile: relativeFilePath} = pathData; + + if (!isContributorsEnabled || !vcsConnector) { + return undefined; + } + + const includedFiles = await getFileIncludes( + getFileDataForContributorsService(pathData, fileContent), + ); + includedFiles.push(relativeFilePath); + + const tocCopyFileMap = TocService.getCopyFileMap(); + + const mtimeList = includedFiles + .map((path) => { + const mappedPath = tocCopyFileMap.get(path) || path; + return vcsConnector.getModifiedTimeByPath(mappedPath); + }) + .filter((v) => typeof v === 'number') as number[]; + + if (mtimeList.length) { + const mtime = Math.max(...mtimeList); + return new Date(mtime * 1000).toISOString(); + } + + return undefined; +}; + +const getAuthorMetadata = async ( + meta: Metadata, + vcsConnector?: VCSConnector, +): Promise => { + if (meta.author && vcsConnector) { + const updatedAuthor = await getAuthorDetails(vcsConnector, meta.author); + + return updatedAuthor; + } + + return null; +}; + +const getContributorsMetadata = (options: MetaDataOptions, fileContent: string) => { + const {isContributorsEnabled, vcsConnector, pathData} = options; + + if (isContributorsEnabled && vcsConnector) { + return getFileContributors( + getFileDataForContributorsService(pathData, fileContent), + vcsConnector, + ); + } + + return Promise.resolve([]); +}; + +export const getVCSMetadata = async ( + options: MetaDataOptions, + fileContent: string, + meta?: Metadata, +): Promise => { + const {vcsConnector} = options; + + const newMetadata: Metadata = { + contributors: (await getContributorsMetadata(options, fileContent)) ?? [], + }; + + if (!meta) { + return newMetadata; + } + + const updatedAuthor = await getAuthorMetadata(meta as Metadata, vcsConnector); + + return { + ...meta, + ...newMetadata, + author: updatedAuthor, + }; +}; + +export const resolveVCSFrontMatter = async ( + existingMetadata: FileMetadata, + options: MetaDataOptions, + fileContent: string, +) => { + const getAuthor = () => { + const {pathData, vcsConnector} = options; + const maybeAuthorFromExistingMeta = existingMetadata.author; + + return typeof maybeAuthorFromExistingMeta === 'string' || + isObject(maybeAuthorFromExistingMeta) + ? updateAuthorMetadataStringByAuthorLogin(maybeAuthorFromExistingMeta, vcsConnector) + : updateAuthorMetadataStringByFilePath(pathData.pathToFile, vcsConnector); + }; + + const [author, contributors, updatedAt] = await Promise.all([ + getAuthor(), + getContributorsMetadata(options, fileContent), + getModifiedTimeISOString(options, fileContent), + ]); + + const authorToSpread = author === null ? undefined : {author}; + const contributorsToSpread = contributors.length > 0 ? {contributors} : undefined; + const updatedAtToSpread = updatedAt ? {updatedAt} : undefined; + + return { + ...authorToSpread, + ...contributorsToSpread, + ...updatedAtToSpread, + }; +}; diff --git a/src/services/tocs.ts b/src/services/tocs.ts index c6df63d3..d4bbcd98 100644 --- a/src/services/tocs.ts +++ b/src/services/tocs.ts @@ -8,12 +8,12 @@ import log from '@diplodoc/transform/lib/log'; import {bold} from 'chalk'; import {ArgvService, PresetService} from './index'; -import {getContentWithUpdatedStaticMetadata} from './metadata'; import {YfmToc} from '../models'; import {IncludeMode, Stage} from '../constants'; import {isExternalHref, logger} from '../utils'; import {filterFiles, firstFilterItem, firstFilterTextItems, liquidField} from './utils'; import {IncludersError, applyIncluders} from './includers'; +import {addSourcePath} from './metadata'; export interface TocServiceData { storage: Map; @@ -279,11 +279,7 @@ function _copyTocDir(tocPath: string, destDir: string) { if (isMdFile) { const fileContent = readFileSync(from, 'utf8'); const sourcePath = relative(inputFolderPath, from); - const updatedFileContent = getContentWithUpdatedStaticMetadata({ - fileContent, - sourcePath, - addSourcePath: true, - }); + const updatedFileContent = addSourcePath(fileContent, sourcePath); writeFileSync(to, updatedFileContent); } else { diff --git a/tests/e2e/__snapshots__/load-custom-resources.spec.ts.snap b/tests/e2e/__snapshots__/load-custom-resources.spec.ts.snap index 60b3cb7f..2cd2818b 100644 --- a/tests/e2e/__snapshots__/load-custom-resources.spec.ts.snap +++ b/tests/e2e/__snapshots__/load-custom-resources.spec.ts.snap @@ -480,7 +480,6 @@ style: - _assets/style/test.css script: - _assets/script/test1.js - --- Lorem @@ -493,7 +492,6 @@ style: - _assets/style/test.css script: - _assets/script/test1.js - --- Lorem" `; diff --git a/tests/integrations/services/metadataAuthors.test.ts b/tests/integrations/services/metadataAuthors.test.ts index 5f87da15..642efcf1 100644 --- a/tests/integrations/services/metadataAuthors.test.ts +++ b/tests/integrations/services/metadataAuthors.test.ts @@ -1,8 +1,7 @@ import {readFileSync} from 'fs'; -import {REGEXP_AUTHOR} from '../../../src/constants'; -import {replaceDoubleToSingleQuotes, сarriage} from '../../../src/utils/markup'; import {MetaDataOptions} from 'models'; -import {getContentWithUpdatedMetadata} from 'services/metadata'; +import {enrichWithFrontMatter} from 'services/metadata'; +import {parseExistingMetadata} from 'services/metadata/parse'; import {VCSConnector} from 'vcs-connector/connector-models'; const authorAliasInMetadataFilePath = 'mocks/fileContent/metadata/authorAliasInMetadata.md'; @@ -10,7 +9,7 @@ const fullAuthorInMetadataFilePath = 'mocks/fileContent/metadata/fullAuthorInMet const simpleMetadataFilePath = 'mocks/fileContent/metadata/simpleMetadata.md'; jest.mock('services/contributors', () => ({ - getFileContributorsMetadata: () => Promise.resolve(''), + getFileContributors: () => Promise.resolve([]), getFileIncludes: () => Promise.resolve([]), })); @@ -24,7 +23,7 @@ describe('getContentWithUpdatedMetadata (Authors)', () => { }; const defaultVCSConnector: VCSConnector = { - addNestedContributorsForPath: () => { }, + addNestedContributorsForPath: () => {}, getContributorsByPath: () => Promise.resolve(null), getUserByLogin: () => Promise.resolve(expectedAuthorData), getExternalAuthorByPath: () => expectedAuthorData, @@ -32,14 +31,23 @@ describe('getContentWithUpdatedMetadata (Authors)', () => { }; describe('should return file content with updated author in metadata', () => { - let metaDataOptions: MetaDataOptions; + let metadataOptions: MetaDataOptions; beforeAll(() => { - metaDataOptions = { - fileData: { - tmpInputFilePath: '', - inputFolderPathLength: 0, - fileContent: '', + metadataOptions = { + pathData: { + pathToFile: '', + resolvedPathToFile: '', + filename: '', + fileBaseName: '', + fileExtension: '', + outputDir: '', + outputPath: '', + outputFormat: '', + outputBundlePath: '', + outputTocDir: '', + inputFolderPath: '', + outputFolderPath: '', }, isContributorsEnabled: true, vcsConnector: defaultVCSConnector, @@ -48,83 +56,132 @@ describe('getContentWithUpdatedMetadata (Authors)', () => { test('if metadata has author alias', async () => { const fileContent = readFileSync(authorAliasInMetadataFilePath, 'utf8'); - const matchAuthor = fileContent.match(REGEXP_AUTHOR); - const updatedFileContent = await getContentWithUpdatedMetadata(fileContent, metaDataOptions); - const expectedFileContent = fileContent - .replace(matchAuthor[0], replaceDoubleToSingleQuotes(JSON.stringify(expectedAuthorData))); + const updatedFileContent = await enrichWithFrontMatter({ + fileContent, + metadataOptions, + resolvedFrontMatterVars: {}, + }); - expect(updatedFileContent).toEqual(expectedFileContent); + const {metadata: originalMeta} = parseExistingMetadata(fileContent); + const {metadata: updatedMeta} = parseExistingMetadata(updatedFileContent); + + const expectedMeta = { + ...originalMeta, + author: expectedAuthorData, + }; + + expect(updatedMeta).toEqual(expectedMeta); }); test('if metadata has full author data', async () => { const fileContent = readFileSync(fullAuthorInMetadataFilePath, 'utf8'); - const matchAuthor = fileContent.match(REGEXP_AUTHOR); - const updatedFileContent = await getContentWithUpdatedMetadata(fileContent, metaDataOptions); - const expectedFileContent = fileContent - .replace(matchAuthor[0], replaceDoubleToSingleQuotes(matchAuthor[0])); + const updatedFileContent = await enrichWithFrontMatter({ + fileContent, + metadataOptions, + resolvedFrontMatterVars: {}, + }); - expect(updatedFileContent).toEqual(expectedFileContent); + const {metadata: originalMeta} = parseExistingMetadata(fileContent); + const {metadata: updatedMeta} = parseExistingMetadata(updatedFileContent); + + expect(updatedMeta).toEqual(originalMeta); }); }); describe('should return file content without updated author in metadata', () => { - const metaDataOptions: MetaDataOptions = { - fileData: { - tmpInputFilePath: '', - inputFolderPathLength: 0, - fileContent: '', + const metadataOptions: MetaDataOptions = { + pathData: { + pathToFile: '', + resolvedPathToFile: '', + filename: '', + fileBaseName: '', + fileExtension: '', + outputDir: '', + outputPath: '', + outputFormat: '', + outputBundlePath: '', + outputTocDir: '', + inputFolderPath: '', + outputFolderPath: '', }, }; test('if metadata options has "isContributorsEnabled" equals false', async () => { - metaDataOptions.isContributorsEnabled = false; - metaDataOptions.vcsConnector = defaultVCSConnector; - const fileContent = readFileSync(authorAliasInMetadataFilePath, 'utf8'); - - const updatedFileContent = await getContentWithUpdatedMetadata(fileContent, metaDataOptions); - - expect(updatedFileContent).toEqual(fileContent); - }); - - test('if metadata options has "isContributorsEnabled" equals true ' + - 'and "vcsConnector" equals undefined', async () => { - metaDataOptions.isContributorsEnabled = true; - metaDataOptions.vcsConnector = undefined; + metadataOptions.isContributorsEnabled = false; + metadataOptions.vcsConnector = defaultVCSConnector; const fileContent = readFileSync(authorAliasInMetadataFilePath, 'utf8'); - const updatedFileContent = await getContentWithUpdatedMetadata(fileContent, metaDataOptions); + const updatedFileContent = await enrichWithFrontMatter({ + fileContent, + metadataOptions, + resolvedFrontMatterVars: {}, + }); expect(updatedFileContent).toEqual(fileContent); }); - test('if metadata options has "isContributorsEnabled" equals true ' + - 'and "getUserByLogin" returns null', async () => { - metaDataOptions.isContributorsEnabled = true; - metaDataOptions.vcsConnector = { - ...defaultVCSConnector, - getUserByLogin: () => Promise.resolve(null), - getExternalAuthorByPath: () => null, - }; - const fileContent = readFileSync(authorAliasInMetadataFilePath, 'utf8'); - - const updatedFileContent = await getContentWithUpdatedMetadata(fileContent, metaDataOptions); - - expect(updatedFileContent).toEqual(fileContent); - }); + test( + 'if metadata options has "isContributorsEnabled" equals true ' + + 'and "vcsConnector" equals undefined', + async () => { + metadataOptions.isContributorsEnabled = true; + metadataOptions.vcsConnector = undefined; + const fileContent = readFileSync(authorAliasInMetadataFilePath, 'utf8'); + + const updatedFileContent = await enrichWithFrontMatter({ + fileContent, + metadataOptions, + resolvedFrontMatterVars: {}, + }); + + expect(updatedFileContent).toEqual(fileContent); + }, + ); + + test( + 'if metadata options has "isContributorsEnabled" equals true ' + + 'and "getUserByLogin" returns null', + async () => { + metadataOptions.isContributorsEnabled = true; + metadataOptions.vcsConnector = { + ...defaultVCSConnector, + getUserByLogin: () => Promise.resolve(null), + getExternalAuthorByPath: () => null, + }; + const fileContent = readFileSync(authorAliasInMetadataFilePath, 'utf8'); + + const updatedFileContent = await enrichWithFrontMatter({ + fileContent, + metadataOptions, + resolvedFrontMatterVars: {}, + }); + + expect(updatedFileContent).toEqual(fileContent); + }, + ); test('if metadata does not have author', async () => { - metaDataOptions.isContributorsEnabled = true; - metaDataOptions.vcsConnector = defaultVCSConnector; + metadataOptions.isContributorsEnabled = true; + metadataOptions.vcsConnector = defaultVCSConnector; const fileContent = readFileSync(simpleMetadataFilePath, 'utf8'); - const updatedFileContent = await getContentWithUpdatedMetadata(fileContent, metaDataOptions); - const lastMetadataRow = 'editable: false'; - const expectedFileContent = fileContent - .replace(lastMetadataRow, replaceDoubleToSingleQuotes(`${lastMetadataRow}${сarriage}author: ${replaceDoubleToSingleQuotes(JSON.stringify(expectedAuthorData))}`)); + const updatedFileContent = await enrichWithFrontMatter({ + fileContent, + metadataOptions, + resolvedFrontMatterVars: {}, + }); + + const {metadata: originalMeta} = parseExistingMetadata(fileContent); + const {metadata: updatedMeta} = parseExistingMetadata(updatedFileContent); + + const expectedMeta = { + ...originalMeta, + author: expectedAuthorData, + }; - expect(updatedFileContent).toEqual(expectedFileContent); + expect(updatedMeta).toEqual(expectedMeta); }); }); }); diff --git a/tests/integrations/services/metadataContributors.test.ts b/tests/integrations/services/metadataContributors.test.ts index 84e046e4..f914a8b9 100644 --- a/tests/integrations/services/metadataContributors.test.ts +++ b/tests/integrations/services/metadataContributors.test.ts @@ -1,9 +1,8 @@ import {readFileSync} from 'fs'; import {normalize} from 'path'; -import {metadataBorder} from '../../../src/constants'; -import {сarriage, replaceDoubleToSingleQuotes} from '../../../src/utils/markup'; import {Contributor, Contributors, MetaDataOptions} from 'models'; -import {getContentWithUpdatedMetadata} from 'services/metadata'; +import {enrichWithFrontMatter} from 'services/metadata'; +import {parseExistingMetadata} from 'services/metadata/parse'; import {VCSConnector} from 'vcs-connector/connector-models'; const simpleMetadataFilePath = 'mocks/fileContent/metadata/simpleMetadata.md'; @@ -13,62 +12,172 @@ const firstIncludeFilePath = 'mocks/fileContent/metadata/includesContent/firstIn const secondIncludeFilePath = 'mocks/fileContent/metadata/includesContent/secondIncludeFile.md'; describe('getContentWithUpdatedMetadata (Contributors)', () => { - const metaDataOptions: MetaDataOptions = { - fileData: { - tmpInputFilePath: '', - inputFolderPathLength: 0, - fileContent: '', + const metadataOptions: MetaDataOptions = { + pathData: { + pathToFile: '', + resolvedPathToFile: '', + filename: '', + fileBaseName: '', + fileExtension: '', + outputDir: '', + outputPath: '', + outputFormat: '', + outputBundlePath: '', + outputTocDir: '', + inputFolderPath: '', + outputFolderPath: '', }, }; const defaultVCSConnector: VCSConnector = { - addNestedContributorsForPath: () => { }, + addNestedContributorsForPath: () => {}, getContributorsByPath: () => Promise.resolve(null), getUserByLogin: () => Promise.resolve(null), getExternalAuthorByPath: () => null, getModifiedTimeByPath: () => undefined, }; - describe('should return file content with updated contributors in metadata ' + - 'if metadata options has "isContributorsEnabled" equals true.', () => { - beforeAll(() => { - metaDataOptions.isContributorsEnabled = true; - metaDataOptions.vcsConnector = defaultVCSConnector; - }); - - test('"getContributorsByPath" does not return any contributors with includes contributors', async () => { - metaDataOptions.vcsConnector.getContributorsByPath = () => Promise.resolve({ - contributors: {}, - hasIncludes: true, + describe( + 'should return file content with updated contributors in metadata ' + + 'if metadata options has "isContributorsEnabled" equals true.', + () => { + beforeAll(() => { + metadataOptions.isContributorsEnabled = true; + metadataOptions.vcsConnector = defaultVCSConnector; }); - const fileContent = readFileSync(simpleMetadataFilePath, 'utf8'); - const updatedFileContent = await getContentWithUpdatedMetadata(fileContent, metaDataOptions); + test('"getContributorsByPath" does not return any contributors with includes contributors', async () => { + metadataOptions.vcsConnector.getContributorsByPath = () => + Promise.resolve({ + contributors: {}, + hasIncludes: true, + }); + const fileContent = readFileSync(simpleMetadataFilePath, 'utf8'); + + const updatedFileContent = await enrichWithFrontMatter({ + fileContent, + metadataOptions, + resolvedFrontMatterVars: {}, + }); - const splitedFiledContent = fileContent.split(metadataBorder); - splitedFiledContent[1] = `${splitedFiledContent[1]}contributors: []${сarriage}`; - const expectedFileContent = splitedFiledContent.join(metadataBorder); - expect(updatedFileContent).toEqual(expectedFileContent); - }); + const {metadata: originalMeta} = parseExistingMetadata(fileContent); + const {metadata: updatedMeta} = parseExistingMetadata(updatedFileContent); - test('File content does not have metadata and' + - '"getContributorsByPath" does not return any contributors with includes contributors', async () => { - metaDataOptions.vcsConnector.getContributorsByPath = () => Promise.resolve({ - contributors: {}, - hasIncludes: true, + expect(updatedMeta).toEqual(originalMeta); }); - const fileContent = readFileSync(withoutMetadataFilePath, 'utf8'); - const updatedFileContent = await getContentWithUpdatedMetadata(fileContent, metaDataOptions); + test( + 'File content does not have metadata and' + + '"getContributorsByPath" does not return any contributors with includes contributors', + async () => { + metadataOptions.vcsConnector.getContributorsByPath = () => + Promise.resolve({ + contributors: {}, + hasIncludes: true, + }); + const fileContent = readFileSync(withoutMetadataFilePath, 'utf8'); + + const updatedFileContent = await enrichWithFrontMatter({ + fileContent, + metadataOptions, + resolvedFrontMatterVars: {}, + }); + + const {metadata: originalMeta} = parseExistingMetadata(fileContent); + const {metadata: updatedMeta} = parseExistingMetadata(updatedFileContent); + + expect(updatedMeta).toEqual(originalMeta); + }, + ); + + test('"getContributorsByPath" returns contributors with includes contributors', async () => { + const contributorFirst: Contributor = { + avatar: 'https://example.ru/logo.png', + name: 'Name Surname 1', + url: 'https://example.ru', + email: 'alias_1@yandex.ru', + login: 'alias_1', + }; + const contributorSecond: Contributor = { + avatar: 'https://example.ru/logo.png', + name: 'Name Surname 2', + url: 'https://example.ru', + email: 'alias_2@yandex.ru', + login: 'alias_2', + }; + const expectedContributors: Contributors = { + [contributorFirst.email]: contributorFirst, + [contributorSecond.email]: contributorSecond, + }; + const expectedContributorsArray: Contributor[] = + Object.values(expectedContributors); + + metadataOptions.vcsConnector.getContributorsByPath = () => + Promise.resolve({ + contributors: expectedContributors, + hasIncludes: true, + }); + const fileContent = readFileSync(simpleMetadataFilePath, 'utf8'); + + const updatedFileContent = await enrichWithFrontMatter({ + fileContent, + metadataOptions, + resolvedFrontMatterVars: {}, + }); - const border = `${metadataBorder}${сarriage}`; - const newMetadata = `${border}contributors: []${сarriage}${border}`; - const expectedFileContent = `${newMetadata}${fileContent}`; - expect(updatedFileContent).toEqual(expectedFileContent); - }); + const {metadata: originalMeta} = parseExistingMetadata(fileContent); + const {metadata: updatedMeta} = parseExistingMetadata(updatedFileContent); + const expectedMeta = { + ...originalMeta, + contributors: expectedContributorsArray, + }; + + expect(updatedMeta).toEqual(expectedMeta); + }); + + test( + '"getContributorsByPath" returns contributors without includes contributors and ' + + 'file content does not have include contents', + async () => { + const contributorFirst: Contributor = { + avatar: 'https://example.ru/logo.png', + name: 'Name Surname 1', + url: 'https://example.ru', + email: 'alias_1@yandex.ru', + login: 'alias_1', + }; + const expectedContributors: Contributors = { + [contributorFirst.email]: contributorFirst, + }; + const expectedContributorsArray: Contributor[] = + Object.values(expectedContributors); + + metadataOptions.vcsConnector.getContributorsByPath = () => + Promise.resolve({ + contributors: expectedContributors, + hasIncludes: false, + }); + const fileContent = readFileSync(simpleMetadataFilePath, 'utf8'); + + const updatedFileContent = await enrichWithFrontMatter({ + fileContent, + metadataOptions, + resolvedFrontMatterVars: {}, + }); + + const {metadata: originalMeta} = parseExistingMetadata(fileContent); + const {metadata: updatedMeta} = parseExistingMetadata(updatedFileContent); + + const expectedMeta = { + ...originalMeta, + contributors: expectedContributorsArray, + }; + + expect(updatedMeta).toEqual(expectedMeta); + }, + ); - test('"getContributorsByPath" returns contributors with includes contributors', async () => { const contributorFirst: Contributor = { avatar: 'https://example.ru/logo.png', name: 'Name Surname 1', @@ -76,167 +185,120 @@ describe('getContentWithUpdatedMetadata (Contributors)', () => { email: 'alias_1@yandex.ru', login: 'alias_1', }; - const contributorSecond: Contributor = { + const includesContributorFromFirstFile: Contributor = { avatar: 'https://example.ru/logo.png', - name: 'Name Surname 2', + name: 'Name Surname includes 1', url: 'https://example.ru', - email: 'alias_2@yandex.ru', - login: 'alias_2', - }; - const expectedContributors: Contributors = { - [contributorFirst.email]: contributorFirst, - [contributorSecond.email]: contributorSecond, + email: 'alias_includes_1@yandex.ru', + login: 'alias_includes_1', }; - const expectedContributorsArray: Contributor[] = Object.values(expectedContributors); - const expectedContributorsString: string = - replaceDoubleToSingleQuotes(JSON.stringify(expectedContributorsArray)); - - metaDataOptions.vcsConnector.getContributorsByPath = () => Promise.resolve({ - contributors: expectedContributors, - hasIncludes: true, - }); - const fileContent = readFileSync(simpleMetadataFilePath, 'utf8'); - - const updatedFileContent = await getContentWithUpdatedMetadata(fileContent, metaDataOptions); - - const splitedFiledContent = fileContent.split(metadataBorder); - splitedFiledContent[1] = - `${splitedFiledContent[1]}contributors: ${expectedContributorsString}${сarriage}`; - const expectedFileContent = splitedFiledContent.join(metadataBorder); - expect(updatedFileContent).toEqual(expectedFileContent); - }); - - test('"getContributorsByPath" returns contributors without includes contributors and ' + - 'file content does not have include contents', async () => { - const contributorFirst: Contributor = { + const includesContributorFromSecondFile: Contributor = { avatar: 'https://example.ru/logo.png', - name: 'Name Surname 1', + name: 'Name Surname includes 2', url: 'https://example.ru', - email: 'alias_1@yandex.ru', - login: 'alias_1', - }; - const expectedContributors: Contributors = { - [contributorFirst.email]: contributorFirst, + email: 'alias_includes_2@yandex.ru', + login: 'alias_includes_2', }; - const expectedContributorsArray: Contributor[] = Object.values(expectedContributors); - const expectedContributorsString: string = - replaceDoubleToSingleQuotes(JSON.stringify(expectedContributorsArray)); - metaDataOptions.vcsConnector.getContributorsByPath = () => Promise.resolve({ - contributors: expectedContributors, - hasIncludes: false, - }); - const fileContent = readFileSync(simpleMetadataFilePath, 'utf8'); + const getFileContributors = (path: string): Contributors => { + if (path === normalize(firstIncludeFilePath)) { + return { + [includesContributorFromFirstFile.email]: includesContributorFromFirstFile, + }; + } - const updatedFileContent = await getContentWithUpdatedMetadata(fileContent, metaDataOptions); + if (path === normalize(secondIncludeFilePath)) { + return { + [includesContributorFromSecondFile.email]: + includesContributorFromSecondFile, + }; + } - const splitedFiledContent = fileContent.split(metadataBorder); - splitedFiledContent[1] = - `${splitedFiledContent[1]}contributors: ${expectedContributorsString}${сarriage}`; - const expectedFileContent = splitedFiledContent.join(metadataBorder); - expect(updatedFileContent).toEqual(expectedFileContent); - }); - - const contributorFirst: Contributor = { - avatar: 'https://example.ru/logo.png', - name: 'Name Surname 1', - url: 'https://example.ru', - email: 'alias_1@yandex.ru', - login: 'alias_1', - }; - const includesContributorFromFirstFile: Contributor = { - avatar: 'https://example.ru/logo.png', - name: 'Name Surname includes 1', - url: 'https://example.ru', - email: 'alias_includes_1@yandex.ru', - login: 'alias_includes_1', - }; - const includesContributorFromSecondFile: Contributor = { - avatar: 'https://example.ru/logo.png', - name: 'Name Surname includes 2', - url: 'https://example.ru', - email: 'alias_includes_2@yandex.ru', - login: 'alias_includes_2', - }; - - const getFileContributors = (path: string): Contributors => { - if (path === normalize(firstIncludeFilePath)) { return { - [includesContributorFromFirstFile.email]: includesContributorFromFirstFile, + [contributorFirst.email]: contributorFirst, }; - } - - if (path === normalize(secondIncludeFilePath)) { - return { - [includesContributorFromSecondFile.email]: includesContributorFromSecondFile, - }; - } - - return { - [contributorFirst.email]: contributorFirst, }; - }; - - [ - { - title: 'when all files does not have information about includes contributors', - getHasIncludes: () => false, - expectedContributorsArray: [ - contributorFirst, - includesContributorFromFirstFile, - includesContributorFromSecondFile, - ], - }, - { - title: 'when first include file has information about includes contributors', - getHasIncludes: (path: string) => path === normalize(firstIncludeFilePath), - expectedContributorsArray: [contributorFirst, includesContributorFromFirstFile], - }, - ].forEach((item) => { - test('"getContributorsByPath" returns contributors from main ' + - `and includes files and ${item.title}`, async () => { - const expectedContributorsString: string = replaceDoubleToSingleQuotes( - JSON.stringify(item.expectedContributorsArray)); - - metaDataOptions.vcsConnector.getContributorsByPath = (path: string) => Promise.resolve({ - contributors: getFileContributors(path), - hasIncludes: item.getHasIncludes(path), - }); - metaDataOptions.fileData.tmpInputFilePath = withIncludesFilePath; - const fileContent = readFileSync(withIncludesFilePath, 'utf8'); - const updatedFileContent = await getContentWithUpdatedMetadata(fileContent, metaDataOptions); - - const splitedFiledContent = fileContent.split(metadataBorder); - splitedFiledContent[1] = - `${splitedFiledContent[1]}contributors: ${expectedContributorsString}${сarriage}`; - const expectedFileContent = splitedFiledContent.join(metadataBorder); - expect(updatedFileContent).toEqual(expectedFileContent); + [ + { + title: 'when all files does not have information about includes contributors', + getHasIncludes: () => false, + expectedContributorsArray: [ + contributorFirst, + includesContributorFromFirstFile, + includesContributorFromSecondFile, + ], + }, + { + title: 'when first include file has information about includes contributors', + getHasIncludes: (path: string) => path === normalize(firstIncludeFilePath), + expectedContributorsArray: [contributorFirst, includesContributorFromFirstFile], + }, + ].forEach((item) => { + test( + '"getContributorsByPath" returns contributors from main ' + + `and includes files and ${item.title}`, + async () => { + metadataOptions.vcsConnector.getContributorsByPath = (path: string) => + Promise.resolve({ + contributors: getFileContributors(path), + hasIncludes: item.getHasIncludes(path), + }); + metadataOptions.pathData.resolvedPathToFile = withIncludesFilePath; + const fileContent = readFileSync(withIncludesFilePath, 'utf8'); + + const updatedFileContent = await enrichWithFrontMatter({ + fileContent, + metadataOptions, + resolvedFrontMatterVars: {}, + }); + + const {metadata: originalMeta} = parseExistingMetadata(fileContent); + const {metadata: updatedMeta} = parseExistingMetadata(updatedFileContent); + + const expectedMeta = { + ...originalMeta, + contributors: item.expectedContributorsArray, + }; + + expect(updatedMeta).toEqual(expectedMeta); + }, + ); }); - }); - }); + }, + ); describe('should return file content without updated contributors in metadata', () => { test('if metadata options has "isContributorsEnabled" equals false', async () => { - metaDataOptions.isContributorsEnabled = false; - metaDataOptions.vcsConnector = defaultVCSConnector; + metadataOptions.isContributorsEnabled = false; + metadataOptions.vcsConnector = defaultVCSConnector; const fileContent = readFileSync(simpleMetadataFilePath, 'utf8'); - const updatedFileContent = await getContentWithUpdatedMetadata(fileContent, metaDataOptions); + const updatedFileContent = await enrichWithFrontMatter({ + fileContent, + metadataOptions, + resolvedFrontMatterVars: {}, + }); expect(updatedFileContent).toEqual(fileContent); }); - test('if metadata options has "isContributorsEnabled" equals true ' + - 'and "vcsConnector" equals undefined', async () => { - metaDataOptions.isContributorsEnabled = true; - metaDataOptions.vcsConnector = undefined; - const fileContent = readFileSync(simpleMetadataFilePath, 'utf8'); - - - const updatedFileContent = await getContentWithUpdatedMetadata(fileContent, metaDataOptions); + test( + 'if metadata options has "isContributorsEnabled" equals true ' + + 'and "vcsConnector" equals undefined', + async () => { + metadataOptions.isContributorsEnabled = true; + metadataOptions.vcsConnector = undefined; + const fileContent = readFileSync(simpleMetadataFilePath, 'utf8'); + + const updatedFileContent = await enrichWithFrontMatter({ + fileContent, + metadataOptions, + resolvedFrontMatterVars: {}, + }); - expect(updatedFileContent).toEqual(fileContent); - }); + expect(updatedFileContent).toEqual(fileContent); + }, + ); }); }); diff --git a/tests/mocks/fileContent/metadata/fullAuthorInMetadata.md b/tests/mocks/fileContent/metadata/fullAuthorInMetadata.md index 1c752f99..95f0f818 100644 --- a/tests/mocks/fileContent/metadata/fullAuthorInMetadata.md +++ b/tests/mocks/fileContent/metadata/fullAuthorInMetadata.md @@ -1,6 +1,9 @@ --- title: Full author in metadata -author: { "avatar": "https://example/icons/logo.png", "name": "Author Name", "url": "https://site.ru" } +author: + avatar: https://example/icons/logo.png + name: Author Name + url: https://site.ru --- # Header diff --git a/tests/package-lock.json b/tests/package-lock.json index eb598f42..ef5abc04 100644 --- a/tests/package-lock.json +++ b/tests/package-lock.json @@ -12,6 +12,7 @@ "@types/node": "^16.11.7", "jest": "^29.7.0", "jest-serializer-html": "^7.1.0", + "js-yaml": "^4.1.0", "ts-jest": "^29.1.1", "ts-node": "^10.4.0", "typescript": "^5.3.3" @@ -655,6 +656,28 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -1462,13 +1485,10 @@ "dev": true }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", @@ -3609,13 +3629,12 @@ "dev": true }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -4195,7 +4214,7 @@ "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, "node_modules/stack-utils": { diff --git a/tests/package.json b/tests/package.json index c43ce33d..f0b16258 100644 --- a/tests/package.json +++ b/tests/package.json @@ -6,6 +6,7 @@ "@types/node": "^16.11.7", "jest": "^29.7.0", "jest-serializer-html": "^7.1.0", + "js-yaml": "^4.1.0", "ts-jest": "^29.1.1", "ts-node": "^10.4.0", "typescript": "^5.3.3" diff --git a/tests/tsconfig.json b/tests/tsconfig.json index ac232ae6..886757aa 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -2,15 +2,11 @@ "compilerOptions": { "target": "es2019", "moduleResolution": "NodeNext", + "module": "NodeNext", "esModuleInterop": true, "paths": { - "*": [ - "../src/*" - ] + "*": ["../src/*"] }, - "types": [ - "node", - "jest" - ] + "types": ["node", "jest"] } } diff --git a/tests/units/services/authors.test.ts b/tests/units/services/authors.test.ts index 2a2240a7..2a106705 100644 --- a/tests/units/services/authors.test.ts +++ b/tests/units/services/authors.test.ts @@ -20,7 +20,7 @@ const author = { const authorByPath: Map = new Map(); const defaultVCSConnector: VCSConnector = { - addNestedContributorsForPath: () => { }, + addNestedContributorsForPath: () => {}, getContributorsByPath: () => Promise.resolve(null), getUserByLogin: () => Promise.resolve(author), getExternalAuthorByPath: (path) => authorByPath.get(path), @@ -28,63 +28,27 @@ const defaultVCSConnector: VCSConnector = { }; describe('getAuthorDetails returns author details', () => { - let spyReplaceDoubleToSingleQuotes: jest.SpyInstance; - - beforeAll(() => { - spyReplaceDoubleToSingleQuotes = jest.spyOn( - units, - 'replaceDoubleToSingleQuotes' - ); - }); - - beforeEach(() => { - spyReplaceDoubleToSingleQuotes.mockClear(); - }); - - afterEach(() => { - expect(spyReplaceDoubleToSingleQuotes).toHaveBeenCalled(); - expect(spyReplaceDoubleToSingleQuotes).toHaveBeenCalledTimes(2); - }); - - afterAll(() => { - jest.clearAllMocks(); - }); - test('when author is object', async () => { - const expectedAuthorDetails = units.replaceDoubleToSingleQuotes( - JSON.stringify(author) - ); + const expectedAuthorDetails = author; - const authorDetails = await getAuthorDetails( - defaultVCSConnector, - author - ); + const authorDetails = await getAuthorDetails(defaultVCSConnector, author); expect(authorDetails).toEqual(expectedAuthorDetails); }); test('when author is stringified object', async () => { const stringifiedObject = JSON.stringify(author); - const expectedAuthorDetails = - units.replaceDoubleToSingleQuotes(stringifiedObject); + const expectedAuthorDetails = author; - const authorDetails = await getAuthorDetails( - defaultVCSConnector, - stringifiedObject - ); + const authorDetails = await getAuthorDetails(defaultVCSConnector, stringifiedObject); expect(authorDetails).toEqual(expectedAuthorDetails); }); test('when author is alias and "getUserByLogin" returns author data by alias', async () => { - const expectedAuthorDetails = units.replaceDoubleToSingleQuotes( - JSON.stringify(author) - ); + const expectedAuthorDetails = author; - const authorDetails = await getAuthorDetails( - defaultVCSConnector, - author.login - ); + const authorDetails = await getAuthorDetails(defaultVCSConnector, author.login); expect(authorDetails).toEqual(expectedAuthorDetails); }); @@ -101,15 +65,9 @@ describe('getAuthorDetails does not return author details', () => { ...defaultVCSConnector, getUserByLogin: () => Promise.resolve(null), }; - const spyReplaceDoubleToSingleQuotes = jest.spyOn( - units, - 'replaceDoubleToSingleQuotes' - ); + const spyReplaceDoubleToSingleQuotes = jest.spyOn(units, 'replaceDoubleToSingleQuotes'); - const authorDetails = await getAuthorDetails( - vcsConnector, - author.login - ); + const authorDetails = await getAuthorDetails(vcsConnector, author.login); expect(authorDetails).toEqual(expectedAuthorDetails); expect(spyReplaceDoubleToSingleQuotes).not.toHaveBeenCalled(); @@ -121,18 +79,16 @@ describe('update author metadata by authorLogin', () => { jest.clearAllMocks(); }); - test('returns empty strring when "vcsConnector" is undefined', async () => { - const expectedMetadata = ''; + test('returns null when "vcsConnector" is undefined', async () => { + const expectedMetadata = null; - const updatedMetadata = await updateAuthorMetadataStringByAuthorLogin( - author.login - ); + const updatedMetadata = await updateAuthorMetadataStringByAuthorLogin(author.login); expect(updatedMetadata).toEqual(expectedMetadata); }); - test('returns empty strring when "getUserByLogin" returns null', async () => { - const expectedMetadata = ''; + test('returns null when "getUserByLogin" returns null', async () => { + const expectedMetadata = null; const vcsConnector = { ...defaultVCSConnector, getUserByLogin: () => Promise.resolve(null), @@ -140,20 +96,18 @@ describe('update author metadata by authorLogin', () => { const authorDetails = await updateAuthorMetadataStringByAuthorLogin( author.login, - vcsConnector + vcsConnector, ); expect(authorDetails).toEqual(expectedMetadata); }); test('returns full author metadata', async () => { - const expectedMetadata = units.replaceDoubleToSingleQuotes( - JSON.stringify(author) - ); + const expectedMetadata = author; const updatedMetadata = await updateAuthorMetadataStringByAuthorLogin( author.login, - defaultVCSConnector + defaultVCSConnector, ); expect(updatedMetadata).toEqual(expectedMetadata); @@ -169,51 +123,44 @@ describe('update author metadata by filePath', () => { jest.clearAllMocks(); }); - test('returns empty strring when "vcsConnector" is undefined', async () => { - const expectedMetadata = ''; + test('returns null when "vcsConnector" is undefined', async () => { + const expectedMetadata = null; - const updatedMetadata = await updateAuthorMetadataStringByFilePath( - filepath - ); + const updatedMetadata = await updateAuthorMetadataStringByFilePath(filepath); expect(updatedMetadata).toEqual(expectedMetadata); }); - test('returns empty strring when "getExternalAuthorByPath" returns null', async () => { - const expectedMetadata = ''; + test('returns null when "getExternalAuthorByPath" returns null', async () => { + const expectedMetadata = null; const vcsConnector = { ...defaultVCSConnector, getExternalAuthorByPath: () => null, }; - const authorDetails = await updateAuthorMetadataStringByFilePath( - filepath, - vcsConnector - ); + const authorDetails = await updateAuthorMetadataStringByFilePath(filepath, vcsConnector); expect(authorDetails).toEqual(expectedMetadata); }); - test('returns empty strring when there is no author for path', async () => { - const expectedMetadata = ''; + test('returns null when there is no author for path', async () => { + const expectedMetadata = null; const filepathWithoutAuthor = 'utils.md'; const authorDetails = await updateAuthorMetadataStringByFilePath( filepathWithoutAuthor, - defaultVCSConnector + defaultVCSConnector, ); expect(authorDetails).toEqual(expectedMetadata); }); test('returns full author metadata', async () => { - const expectedMetadata = units.replaceDoubleToSingleQuotes( - JSON.stringify(author) - ); + const expectedMetadata = author; const updatedMetadata = await updateAuthorMetadataStringByFilePath( filepath, - defaultVCSConnector + defaultVCSConnector, ); expect(updatedMetadata).toEqual(expectedMetadata); diff --git a/tests/units/services/metadata.test.ts b/tests/units/services/metadata.test.ts index 3cefec68..d4443f47 100644 --- a/tests/units/services/metadata.test.ts +++ b/tests/units/services/metadata.test.ts @@ -21,10 +21,9 @@ const contributors: Contributors = { [contributorFirst.email]: contributorFirst, }; const contributorsArray: Contributor[] = Object.values(contributors); -const contributorsString: string = replaceDoubleToSingleQuotes(JSON.stringify(contributorsArray)); jest.mock('services/contributors', () => ({ - getFileContributorsString: () => Promise.resolve(contributorsString), + getFileContributors: () => Promise.resolve(contributorsArray), getFileIncludes: () => Promise.resolve([]), })); @@ -47,10 +46,19 @@ describe('getUpdatedMetadata', () => { beforeEach(() => { metaDataOptions = { - fileData: { - tmpInputFilePath: '', - inputFolderPathLength: 0, - fileContent: '', + pathData: { + pathToFile: '', + resolvedPathToFile: '', + filename: '', + fileBaseName: '', + fileExtension: '', + outputDir: '', + outputPath: '', + outputFormat: '', + outputBundlePath: '', + outputTocDir: '', + inputFolderPath: '', + outputFolderPath: '', }, isContributorsEnabled: true, vcsConnector: defaultVCSConnector, @@ -60,7 +68,7 @@ describe('getUpdatedMetadata', () => { test('returns new metadata with empty contributors when "isContributorsEnabled" is false', async () => { const fileContent = ''; const expectedMetadata = { - contributors: '[]', + contributors: [], }; metaDataOptions.isContributorsEnabled = false; @@ -72,7 +80,7 @@ describe('getUpdatedMetadata', () => { test('returns new metadata with empty contributors when "vcsConnector" is undefined', async () => { const fileContent = ''; const expectedMetadata = { - contributors: '[]', + contributors: [], }; metaDataOptions.vcsConnector = undefined; @@ -87,7 +95,7 @@ describe('getUpdatedMetadata', () => { async () => { const fileContent = ''; const expectedMetadata = { - contributors: contributorsString, + contributors: contributorsArray, }; const newMetadata = await getVCSMetadata(metaDataOptions, fileContent); @@ -106,7 +114,7 @@ describe('getUpdatedMetadata', () => { }; const expectedMetadata = { ...meta, - contributors: '[]', + contributors: [], author: null, }; metaDataOptions.isContributorsEnabled = false; @@ -128,7 +136,7 @@ describe('getUpdatedMetadata', () => { }; const expectedMetadata = { ...meta, - contributors: '[]', + contributors: [], author: authorString, }; metaDataOptions.isContributorsEnabled = false; @@ -150,7 +158,7 @@ describe('getUpdatedMetadata', () => { }; const expectedMetadata = { ...meta, - contributors: '[]', + contributors: [], author: null, }; metaDataOptions.isContributorsEnabled = false;