From 99dc83e13cba8970628f52d16603d98f9cdb04a8 Mon Sep 17 00:00:00 2001 From: Daniel Kuznetsov Date: Thu, 11 Jul 2024 20:40:30 +0300 Subject: [PATCH 1/4] Revert "fix: Revert "feat: implement an option to always provide `vcsPath` in metadata for md->md transformations (sans tests)"" This reverts commit a3455a53b4aefb7c06676078a572fbdc4e2db701. --- src/models.ts | 38 ++++-- src/resolvers/md2md.ts | 13 ++- src/services/contributors.ts | 35 +++--- src/services/metadata.ts | 216 ++++++++++++++++++++--------------- src/steps/processPages.ts | 22 +--- src/utils/markup.ts | 4 +- 6 files changed, 185 insertions(+), 143 deletions(-) diff --git a/src/models.ts b/src/models.ts index efee4e82..761f2c08 100644 --- a/src/models.ts +++ b/src/models.ts @@ -27,6 +27,31 @@ export type UserByLoginFunction = (login: string) => Promise export type CollectionOfPluginsFunction = (output: string, options: PluginOptions) => string; export type GetModifiedTimeByPathFunction = (filepath: string) => number | undefined; +/** + * VCS integration configuration object. + * Future VCS futures should be configured with this one, not with + * `VCSConnectorConfig`. + */ +interface VCSConfiguration { + /** + * Externally accessible base URI for a resource where a particular documentation + * source is hosted. + * + * This configuration parameter is used to directly control the Edit button behaviour + * in the Diplodoc documentation viewer(s). + * + * For example, if the following applies: + * - Repo with doc source is hosted on GitHub (say, https://github.com/foo-org/bar), + * - Within that particular repo, the directory that is being passed as an `--input` + * parameter to the CLI is located at `docs/`, + * - Whenever the Edit button is pressed, you wish to direct your readers to the + * respective document's source on `main` branch + * + * you should pass `https://github.com/foo-org/bar/tree/main/docs` as a value for this parameter. + */ + remoteBase: string; +} + interface YfmConfig { varsPreset: VarsPreset; ignore: string[]; @@ -41,6 +66,7 @@ interface YfmConfig { ignoreStage: string; singlePage: boolean; removeHiddenTocItems: boolean; + vcs?: VCSConfiguration; connector?: VCSConnectorConfig; lang?: Lang; langs?: Lang[]; @@ -199,20 +225,14 @@ export interface Contributors { [email: string]: Contributor; } -export interface FileData { - tmpInputFilePath: string; - inputFolderPathLength: number; - fileContent: string; - sourcePath?: string; -} - export interface MetaDataOptions { - fileData: FileData; + pathData: PathData; isContributorsEnabled?: boolean; vcsConnector?: VCSConnector; addSystemMeta?: boolean; addSourcePath?: boolean; resources?: Resources; + shouldAlwaysAddVCSPath?: boolean; } export interface PluginOptions { @@ -236,7 +256,7 @@ export interface Plugin { export interface ResolveMd2MdOptions { inputPath: string; outputPath: string; - metadata?: MetaDataOptions; + metadata: MetaDataOptions; } export interface ResolverOptions { diff --git a/src/resolvers/md2md.ts b/src/resolvers/md2md.ts index 368c12f5..ae63ddf6 100644 --- a/src/resolvers/md2md.ts +++ b/src/resolvers/md2md.ts @@ -12,16 +12,17 @@ import {getContentWithUpdatedMetadata} from '../services/metadata'; import {ChangelogItem} from '@diplodoc/transform/lib/plugins/changelog/types'; export async function resolveMd2Md(options: ResolveMd2MdOptions): Promise { - const {inputPath, outputPath, metadata} = options; + const {inputPath, outputPath, metadata: metadataOptions} = options; const {input, output, changelogs: changelogsSetting} = ArgvService.getConfig(); const resolvedInputPath = resolve(input, inputPath); - const vars = getVarsPerFile(inputPath); + + const varsPreset = getVarsPerFile(inputPath); const content = await getContentWithUpdatedMetadata( readFileSync(resolvedInputPath, 'utf8'), - metadata, - vars.__system, - vars.__metadata, + metadataOptions, + varsPreset.__system as unknown, + varsPreset.__metadata, ); const {result, changelogs} = transformMd2Md(content, { @@ -30,7 +31,7 @@ export async function resolveMd2Md(options: ResolveMd2MdOptions): Promise root: resolve(input), destRoot: resolve(output), collectOfPlugins: PluginService.getCollectOfPlugins(), - vars, + vars: varsPreset, log, copyFile, }); diff --git a/src/services/contributors.ts b/src/services/contributors.ts index 6d4c2303..ab6a0fb1 100644 --- a/src/services/contributors.ts +++ b/src/services/contributors.ts @@ -3,11 +3,17 @@ import {dirname, join} from 'path'; import {replaceDoubleToSingleQuotes} from '../utils'; import {REGEXP_INCLUDE_CONTENTS, REGEXP_INCLUDE_FILE_PATH} from '../constants'; -import {Contributor, Contributors, FileData} from '../models'; +import {Contributor, Contributors} from '../models'; import {FileContributors, VCSConnector} from '../vcs-connector/connector-models'; +export interface ContributorsServiceFileData { + resolvedFilePath: string; + inputFolderPathLength: number; + fileContent: string; +} + async function getFileContributorsMetadata( - fileData: FileData, + fileData: ContributorsServiceFileData, vcsConnector: VCSConnector, ): Promise { const contributors = await getFileContributorsString(fileData, vcsConnector); @@ -16,12 +22,12 @@ async function getFileContributorsMetadata( } async function getFileContributorsString( - fileData: FileData, + fileData: ContributorsServiceFileData, vcsConnector: VCSConnector, ): Promise { - const {tmpInputFilePath, inputFolderPathLength} = fileData; + const {resolvedFilePath, inputFolderPathLength} = fileData; - const relativeFilePath = tmpInputFilePath.substring(inputFolderPathLength); + const relativeFilePath = resolvedFilePath.substring(inputFolderPathLength); const fileContributors: FileContributors = await vcsConnector.getContributorsByPath(relativeFilePath); let nestedContributors: Contributors = {}; @@ -44,7 +50,7 @@ async function getFileContributorsString( } async function getContributorsForNestedFiles( - fileData: FileData, + fileData: ContributorsServiceFileData, vcsConnector: VCSConnector, ): Promise { const {fileContent, inputFolderPathLength} = fileData; @@ -77,10 +83,10 @@ async function getContributorsForNestedFiles( throw err; } - const newFileData: FileData = { + const newFileData: ContributorsServiceFileData = { ...fileData, fileContent: contentIncludeFile, - tmpInputFilePath: relativeIncludeFilePath, + resolvedFilePath: relativeIncludeFilePath, }; nestedContributors = await getContributorsForNestedFiles(newFileData, vcsConnector); @@ -95,10 +101,9 @@ async function getContributorsForNestedFiles( } function getRelativeIncludeFilePaths( - fileData: Pick, + {resolvedFilePath: tmpInputFilePath}: ContributorsServiceFileData, includeContents: string[], ): Set { - const {tmpInputFilePath} = fileData; const relativeIncludeFilePaths: Set = new Set(); includeContents.forEach((includeContent: string) => { @@ -118,10 +123,8 @@ function getRelativeIncludeFilePaths( return relativeIncludeFilePaths; } -async function getFileIncludes( - fileData: Pick, -) { - const {fileContent, tmpInputFilePath, inputFolderPathLength} = fileData; +async function getFileIncludes(fileData: ContributorsServiceFileData) { + const {fileContent, inputFolderPathLength} = fileData; const results = new Set(); @@ -130,7 +133,7 @@ async function getFileIncludes( return []; } const relativeIncludeFilePaths: Set = getRelativeIncludeFilePaths( - {tmpInputFilePath}, + fileData, includeContents, ); for (const relativeIncludeFilePath of relativeIncludeFilePaths.values()) { @@ -151,7 +154,7 @@ async function getFileIncludes( const includedPaths = await getFileIncludes({ inputFolderPathLength, fileContent: contentIncludeFile, - tmpInputFilePath: relativeIncludeFilePath, + resolvedFilePath: relativeIncludeFilePath, }); includedPaths.forEach((path) => results.add(path)); } diff --git a/src/services/metadata.ts b/src/services/metadata.ts index 24bcb424..4c883259 100644 --- a/src/services/metadata.ts +++ b/src/services/metadata.ts @@ -1,43 +1,92 @@ import {dump, load} from 'js-yaml'; - import {VCSConnector} from '../vcs-connector/connector-models'; -import {MetaDataOptions, Metadata, Resources, VarsMetadata, YfmToc} from '../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 {сarriage} 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, + options: MetaDataOptions, systemVars?: unknown, metadataVars?: VarsMetadata, ): Promise { - let result; + const {pathData, addSystemMeta, addSourcePath, resources} = options ?? {}; - result = getContentWithUpdatedStaticMetadata({ + const withUpdatedStaticMeta = getContentWithUpdatedStaticMetadata({ fileContent, - sourcePath: options?.fileData?.sourcePath, - addSystemMeta: options?.addSystemMeta, - addSourcePath: options?.addSourcePath, - resources: options?.resources, + sourcePath: pathData?.pathToFile, + addSystemMeta, + addSourcePath, + resources, systemVars, metadataVars, + additionalMetadata: resolveAdditionalMetadata(options), }); - result = await getContentWithUpdatedDynamicMetadata(result, options); + return await getContentWithUpdatedDynamicMetadata(withUpdatedStaticMeta, options); +} - return result; +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({ @@ -47,6 +96,7 @@ function getContentWithUpdatedStaticMetadata({ addSourcePath, resources, systemVars, + additionalMetadata, metadataVars = [], }: { fileContent: string; @@ -55,59 +105,36 @@ function getContentWithUpdatedStaticMetadata({ addSourcePath?: boolean; resources?: Resources; systemVars?: unknown; + additionalMetadata?: Record; metadataVars?: VarsMetadata; }): string { - const newMetadatas: string[] = []; - - if ( - (!addSystemMeta || !systemVars) && - !addSourcePath && - !resources && - metadataVars.length === 0 - ) { - return fileContent; - } - - const matches = matchMetadata(fileContent); - - if (addSystemMeta && systemVars && isObject(systemVars)) { - newMetadatas.push(getSystemVarsMetadataString(systemVars)); - } - - if (resources) { - newMetadatas.push(dump(resources)); - } - - if (addSourcePath && sourcePath) { - const sourcePathMetadataString = `sourcePath: ${sourcePath}`; - newMetadatas.push(sourcePathMetadataString); - } - - if (matches && matches.length > 0) { - const [, fileMetadata, , fileMainContent] = matches; - - if (!metadataVars.length) { - return `${getUpdatedMetadataString(newMetadatas, fileMetadata)}${fileMainContent}`; - } - - const parsed = load(fileMetadata) as Record; - - if (!Array.isArray(parsed.metadata)) { - parsed.metadata = [parsed.metadata]; - } - - parsed.metadata = parsed.metadata.concat(metadataVars).filter(Boolean); - - const patchedMetada = dump(parsed); - - return `${getUpdatedMetadataString(newMetadatas, patchedMetada)}${fileMainContent}`; - } - - if (metadataVars.length) { - newMetadatas.push(dump({metadata: metadataVars})); - } + 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 `${getUpdatedMetadataString(newMetadatas)}${fileContent}`; + return `${serializeMetadata(mergedMetadata)}${metadataStrippedContent}`; } async function getContentWithUpdatedDynamicMetadata( @@ -154,10 +181,7 @@ async function getContentWithUpdatedDynamicMetadata( } if (!authorMetadata) { - const { - fileData: {tmpInputFilePath, inputFolderPathLength}, - } = options; - const relativeFilePath = tmpInputFilePath.substring(inputFolderPathLength); + const {pathToFile: relativeFilePath} = options.pathData; authorMetadata = await updateAuthorMetadataStringByFilePath( relativeFilePath, options.vcsConnector, @@ -199,43 +223,52 @@ function matchMetadata(fileContent: string) { // Search by format: // --- // main content 123 - const regexpFileContent = '-{3}((.*[\r?\n]*)*)'; + 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, fileData} = options; + const {isContributorsEnabled, vcsConnector, pathData} = options; if (isContributorsEnabled && vcsConnector) { - const updatedFileData = { - ...fileData, - fileContent, - }; - - return getFileContributorsMetadata(updatedFileData, vcsConnector); + return getFileContributorsMetadata( + getFileDataForContributorsService(pathData, fileContent), + vcsConnector, + ); } return undefined; } async function getModifiedTimeMetadataString(options: MetaDataOptions, fileContent: string) { - const {isContributorsEnabled, vcsConnector, fileData} = options; - - const {tmpInputFilePath, inputFolderPathLength} = fileData; + const {isContributorsEnabled, vcsConnector, pathData} = options; - const relativeFilePath = tmpInputFilePath.substring(inputFolderPathLength + 1); + const {pathToFile: relativeFilePath} = pathData; if (!isContributorsEnabled || !vcsConnector) { return undefined; } - const includedFiles = await getFileIncludes({...fileData, fileContent}); + const includedFiles = await getFileIncludes( + getFileDataForContributorsService(pathData, fileContent), + ); includedFiles.push(relativeFilePath); const tocCopyFileMap = TocService.getCopyFileMap(); @@ -256,13 +289,14 @@ async function getModifiedTimeMetadataString(options: MetaDataOptions, fileConte } function getUpdatedMetadataString(newMetadatas: string[], defaultMetadata = ''): string { - const newMetadata = newMetadatas.join(сarriage) + (newMetadatas.length ? сarriage : ''); + const newMetadata = + newMetadatas.join(carriageReturn) + (newMetadatas.length ? carriageReturn : ''); const preparedDefaultMetadata = defaultMetadata.trimRight(); - const defaultMetadataСarriage = preparedDefaultMetadata ? сarriage : ''; + const defaultMetadataСarriage = preparedDefaultMetadata ? carriageReturn : ''; const updatedMetadata = `${preparedDefaultMetadata}${defaultMetadataСarriage}${newMetadata}`; - return `${metadataBorder}${сarriage}${updatedMetadata}${metadataBorder}${ - defaultMetadata.length ? '' : сarriage + return `${metadataBorder}${carriageReturn}${updatedMetadata}${metadataBorder}${ + defaultMetadata.length ? '' : carriageReturn }`; } @@ -294,15 +328,13 @@ async function getContributorsMetadata( options: MetaDataOptions, fileContent: string, ): Promise { - const {isContributorsEnabled, vcsConnector, fileData} = options; + const {isContributorsEnabled, vcsConnector, pathData} = options; if (isContributorsEnabled && vcsConnector) { - const updatedFileData = { - ...fileData, - fileContent, - }; - - return getFileContributorsString(updatedFileData, vcsConnector); + return getFileContributorsString( + getFileDataForContributorsService(pathData, fileContent), + vcsConnector, + ); } return JSON.stringify([]); @@ -321,10 +353,6 @@ async function getAuthorMetadata( return null; } -function getSystemVarsMetadataString(systemVars: object) { - return `__system: ${JSON.stringify(systemVars)}`; -} - function getAssetsPublicPath(filePath: string) { const toc: YfmToc | null = TocService.getForPath(filePath) || null; diff --git a/src/steps/processPages.ts b/src/steps/processPages.ts index a6ba56d9..b96026fc 100644 --- a/src/steps/processPages.ts +++ b/src/steps/processPages.ts @@ -67,11 +67,7 @@ export async function processPages(outputBundlePath: string): Promise { logger.proc(pathToFile); - const metaDataOptions = getMetaDataOptions( - pathData, - inputFolderPath.length, - vcsConnector, - ); + const metaDataOptions = getMetaDataOptions(pathData, vcsConnector); await preparingPagesByOutputFormat( pathData, @@ -226,22 +222,16 @@ function savePageResultForSinglePage(pageProps: DocInnerProps, pathData: PathDat }); } -function getMetaDataOptions( - pathData: PathData, - inputFolderPathLength: number, - vcsConnector?: VCSConnector, -): MetaDataOptions { - const {contributors, addSystemMeta, resources, allowCustomResources} = ArgvService.getConfig(); +function getMetaDataOptions(pathData: PathData, vcsConnector?: VCSConnector): MetaDataOptions { + const {contributors, addSystemMeta, resources, allowCustomResources, vcs} = + ArgvService.getConfig(); const metaDataOptions: MetaDataOptions = { + pathData, vcsConnector, - fileData: { - tmpInputFilePath: pathData.resolvedPathToFile, - inputFolderPathLength, - fileContent: '', - }, isContributorsEnabled: Boolean(contributors && vcsConnector), addSystemMeta, + shouldAlwaysAddVCSPath: typeof vcs?.remoteBase === 'string' && vcs.remoteBase.length > 0, }; if (allowCustomResources && resources) { diff --git a/src/utils/markup.ts b/src/utils/markup.ts index 6b854faf..6a94fae7 100644 --- a/src/utils/markup.ts +++ b/src/utils/markup.ts @@ -13,7 +13,7 @@ import {isFileExists, resolveRelativePath} from '@diplodoc/transform/lib/utilsFS import {escape} from 'html-escaper'; -export const сarriage = platform === Platforms.WINDOWS ? '\r\n' : '\n'; +export const carriageReturn = platform === Platforms.WINDOWS ? '\r\n' : '\n'; export interface TitleMeta { title?: string; @@ -117,7 +117,7 @@ function getMetadata(metadata: VarsMetadata | undefined, restMeta: LeadingPage[' }, ''); if (args.length) { - result += `` + сarriage; + result += `` + carriageReturn; } }; From 27e45b1c9fe9e6bafcbc59382590c62e6ee9a4ce Mon Sep 17 00:00:00 2001 From: Daniel Kuznetsov Date: Thu, 11 Jul 2024 20:40:40 +0300 Subject: [PATCH 2/4] Revert "Revert "feat: resolve `vcsPath` correctly when `sourcePath` was supplied beforehand, refactor metadata generation procedures"" This reverts commit 1c251e51970e9f88b6c5f9d3f7e65c983af13b4f. --- src/models.ts | 1 - src/resolvers/md2md.ts | 18 +- src/services/authors.ts | 33 +- src/services/contributors.ts | 21 +- src/services/metadata.ts | 378 ---------------- src/services/metadata/addSourcePath.ts | 11 + src/services/metadata/assetPaths.ts | 18 + src/services/metadata/enrich.ts | 53 +++ src/services/metadata/index.ts | 4 + src/services/metadata/mergeMetadata.ts | 42 ++ src/services/metadata/parse.ts | 84 ++++ src/services/metadata/utils.ts | 9 + src/services/metadata/vcsMetadata.ts | 135 ++++++ src/services/tocs.ts | 8 +- .../load-custom-resources.spec.ts.snap | 2 - .../services/metadataAuthors.test.ts | 179 +++++--- .../services/metadataContributors.test.ts | 418 ++++++++++-------- .../metadata/fullAuthorInMetadata.md | 5 +- tests/package-lock.json | 45 +- tests/package.json | 1 + tests/tsconfig.json | 10 +- tests/units/services/authors.test.ts | 109 ++--- tests/units/services/metadata.test.ts | 32 +- 23 files changed, 833 insertions(+), 783 deletions(-) delete mode 100644 src/services/metadata.ts create mode 100644 src/services/metadata/addSourcePath.ts create mode 100644 src/services/metadata/assetPaths.ts create mode 100644 src/services/metadata/enrich.ts create mode 100644 src/services/metadata/index.ts create mode 100644 src/services/metadata/mergeMetadata.ts create mode 100644 src/services/metadata/parse.ts create mode 100644 src/services/metadata/utils.ts create mode 100644 src/services/metadata/vcsMetadata.ts 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; From 8275680da2a92a1b179d871a103b4340923c013f Mon Sep 17 00:00:00 2001 From: Daniel Kuznetsov Date: Thu, 11 Jul 2024 17:36:10 +0300 Subject: [PATCH 3/4] fix: use different escaping strategy for Liquid-style substitutions in YAML format front matter --- src/services/metadata/parse.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/metadata/parse.ts b/src/services/metadata/parse.ts index 390d8aa9..1d360055 100644 --- a/src/services/metadata/parse.ts +++ b/src/services/metadata/parse.ts @@ -41,7 +41,7 @@ const matchMetadata = (fileContent: string) => { * @returns String with `{}` escaped, ready to be parsed with `js-yaml` */ const escapeLiquidSubstitutionSyntax = (content: string): string => - content.replace(/{{/g, '\\{\\{').replace(/}}/g, '\\}\\}'); + content.replace(/{{/g, '(({{').replace(/}}/g, '}}))'); /** * Inverse of a workaround defined above. @@ -50,7 +50,7 @@ const escapeLiquidSubstitutionSyntax = (content: string): string => * @returns Unescaped string */ const unescapeLiquidSubstitutionSyntax = (escapedContent: string): string => - escapedContent.replace(/\\{\\{/g, '{{').replace(/\\}\\}/g, '}}'); + escapedContent.replace(/\(\({{/g, '{{').replace(/}}\)\)/g, '}}'); export const parseExistingMetadata = (fileContent: string): ParseExistingMetadataReturn => { const matches = matchMetadata(fileContent); From bee51f5e95604b2f70e7850326d9852cebb64f64 Mon Sep 17 00:00:00 2001 From: Daniel Kuznetsov Date: Fri, 12 Jul 2024 15:37:04 +0300 Subject: [PATCH 4/4] feat: add tests for metadata transformations with substitutions --- src/services/metadata/utils.ts | 3 +- .../liquidInFrontMatter.test.ts.snap | 49 +++++++++++++++++++ .../services/liquidInFrontMatter.test.ts | 28 +++++++++++ .../substitutionsInMetadataPropertyKeys.md | 7 +++ .../substitutionsInMetadataPropertyValues.md | 11 +++++ 5 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/integrations/services/__snapshots__/liquidInFrontMatter.test.ts.snap create mode 100644 tests/integrations/services/liquidInFrontMatter.test.ts create mode 100644 tests/mocks/fileContent/metadata/substitutionsInMetadataPropertyKeys.md create mode 100644 tests/mocks/fileContent/metadata/substitutionsInMetadataPropertyValues.md diff --git a/src/services/metadata/utils.ts b/src/services/metadata/utils.ts index bae2d7ae..5d9674a6 100644 --- a/src/services/metadata/utils.ts +++ b/src/services/metadata/utils.ts @@ -3,7 +3,8 @@ 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 normalizeLineEndings = (input: string): string => + input.replace(/\r?\n/g, carriageReturn); export const emplaceMetadata = (metadataStrippedContent: string, metadata: FileMetadata) => normalizeLineEndings(`${serializeMetadata(metadata)}${metadataStrippedContent}`); diff --git a/tests/integrations/services/__snapshots__/liquidInFrontMatter.test.ts.snap b/tests/integrations/services/__snapshots__/liquidInFrontMatter.test.ts.snap new file mode 100644 index 00000000..9daa215b --- /dev/null +++ b/tests/integrations/services/__snapshots__/liquidInFrontMatter.test.ts.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Front matter (metadata) transformations do not break when a property key contains Liquid-style variable substitutions 1`] = ` +{ + "(({{ key-name }}))": "This one's key employs a different whitespace style in a substitution.", + "(({{key-name}}))": "This one's key only consists of a substitution.", + "(({{key-name}}))-prop": "This one's key starts with a substitution.", + "prop-(({{key-name}}))": "This one has a substitution in a key name.", +} +`; + +exports[`Front matter (metadata) transformations do not break when a property key contains Liquid-style variable substitutions 2`] = ` +"--- +prop-{{key-name}}: This one has a substitution in a key name. +{{key-name}}-prop: This one's key starts with a substitution. +{{key-name}}: This one's key only consists of a substitution. +{{ key-name }}: This one's key employs a different whitespace style in a substitution. +--- +Blah. +" +`; + +exports[`Front matter (metadata) transformations do not break when a property value contains Liquid-style variable substitutions 1`] = ` +{ + "prop1": "This is a metadata property with a (({{substitution}})) in it.", + "prop2": "This one contains (({{multiple}})) (({{substitutions}})).", + "prop3": "This one has (({{substitutions}})) of (({{ different }})) (({{ styles}})).", + "prop4": "This one has a (({{substitution}})) as well, but the string literal is single-quoted.", + "prop5": "This one has no quotes at (({{all}})).", + "prop6": "(({{this}})) starts with a substitution.", + "prop7": "(({{this}})) one is a multiline (({{property}})).", +} +`; + +exports[`Front matter (metadata) transformations do not break when a property value contains Liquid-style variable substitutions 2`] = ` +"--- +prop1: This is a metadata property with a {{substitution}} in it. +prop2: This one contains {{multiple}} {{substitutions}}. +prop3: This one has {{substitutions}} of {{ different }} {{ styles}}. +prop4: >- + This one has a {{substitution}} as well, but the string literal is + single-quoted. +prop5: This one has no quotes at {{all}}. +prop6: {{this}} starts with a substitution. +prop7: {{this}} one is a multiline {{property}}. +--- +Blah. +" +`; diff --git a/tests/integrations/services/liquidInFrontMatter.test.ts b/tests/integrations/services/liquidInFrontMatter.test.ts new file mode 100644 index 00000000..e6472891 --- /dev/null +++ b/tests/integrations/services/liquidInFrontMatter.test.ts @@ -0,0 +1,28 @@ +import {readFile} from 'fs/promises'; +import {parseExistingMetadata} from 'services/metadata/parse'; +import {emplaceMetadata} from 'services/metadata/utils'; + +const propValuesMockPath = 'mocks/fileContent/metadata/substitutionsInMetadataPropertyValues.md'; +const propKeysMockPath = 'mocks/fileContent/metadata/substitutionsInMetadataPropertyKeys.md'; + +describe('Front matter (metadata) transformations', () => { + it('do not break when a property value contains Liquid-style variable substitutions', async () => { + const fileContent = await readFile(propValuesMockPath, {encoding: 'utf-8'}); + + const {metadata, metadataStrippedContent} = parseExistingMetadata(fileContent); + const processedContent = emplaceMetadata(metadataStrippedContent, metadata); + + expect(metadata).toMatchSnapshot(); + expect(processedContent).toMatchSnapshot(); + }); + + it('do not break when a property key contains Liquid-style variable substitutions', async () => { + const fileContent = await readFile(propKeysMockPath, {encoding: 'utf-8'}); + + const {metadata, metadataStrippedContent} = parseExistingMetadata(fileContent); + const processedContent = emplaceMetadata(metadataStrippedContent, metadata); + + expect(metadata).toMatchSnapshot(); + expect(processedContent).toMatchSnapshot(); + }); +}); diff --git a/tests/mocks/fileContent/metadata/substitutionsInMetadataPropertyKeys.md b/tests/mocks/fileContent/metadata/substitutionsInMetadataPropertyKeys.md new file mode 100644 index 00000000..02660a2a --- /dev/null +++ b/tests/mocks/fileContent/metadata/substitutionsInMetadataPropertyKeys.md @@ -0,0 +1,7 @@ +--- +prop-{{key-name}}: This one has a substitution in a key name. +{{key-name}}-prop: This one's key starts with a substitution. +{{key-name}}: This one's key only consists of a substitution. +{{ key-name }}: This one's key employs a different whitespace style in a substitution. +--- +Blah. diff --git a/tests/mocks/fileContent/metadata/substitutionsInMetadataPropertyValues.md b/tests/mocks/fileContent/metadata/substitutionsInMetadataPropertyValues.md new file mode 100644 index 00000000..89418583 --- /dev/null +++ b/tests/mocks/fileContent/metadata/substitutionsInMetadataPropertyValues.md @@ -0,0 +1,11 @@ +--- +prop1: "This is a metadata property with a {{substitution}} in it." +prop2: "This one contains {{multiple}} {{substitutions}}." +prop3: "This one has {{substitutions}} of {{ different }} {{ styles}}." +prop4: 'This one has a {{substitution}} as well, but the string literal is single-quoted.' +prop5: This one has no quotes at {{all}}. +prop6: {{this}} starts with a substitution. +prop7: >- + {{this}} one is a multiline {{property}}. +--- +Blah.